diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 70c74b9..0e42fc2 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -2,7 +2,7 @@ name: Docker Build on: push: - branches: [ "main" ] + branches: [ "main" , "feature/ui-login-rework"] paths-ignore: - 'README.md' - '.gitignore' diff --git a/README.md b/README.md index 6a73c23..8f9faae 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ VIDEO_URL=http://localhost:8457/video 1. Clone this repo and navigate into it: ```bash -git clone https://github.com/wander/ta-organizerr.git +git clone https://github.com/Salpertio/tubesorter.git cd tubesorter ``` diff --git a/ta_symlink.py b/ta_symlink.py index 81e3651..9aec3f2 100644 --- a/ta_symlink.py +++ b/ta_symlink.py @@ -9,7 +9,7 @@ import ipaddress import shutil from datetime import datetime from functools import wraps -from flask import Flask, jsonify, render_template, request, abort, Response, send_from_directory +from flask import Flask, jsonify, render_template, request, abort, Response, send_from_directory, session, redirect, url_for # Load config from environment variables API_URL = os.getenv("API_URL", "http://localhost:8457/api") @@ -23,11 +23,14 @@ SOURCE_DIR = Path("/app/source") TARGET_DIR = Path("/app/target") HIDDEN_DIR = Path("/app/hidden") IMPORT_DIR = Path("/app/import") +DATA_DIR = Path("/app/data") +HOST_SOURCE_BASE = Path(os.getenv("HOST_SOURCE_BASE", "/mnt/user/tubearchives/bp")) HEADERS = {"Authorization": f"Token {API_TOKEN}"} # Serve static files from ui/dist STATIC_FOLDER = os.path.join(os.getcwd(), 'ui', 'dist') app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path='/') +app.secret_key = os.getenv("FLASK_SECRET_KEY", "tubesortermagicpika") # Change in production! # Database setup import sqlite3 @@ -55,6 +58,7 @@ def init_db(): published TEXT, symlink TEXT, status TEXT, + is_live BOOLEAN DEFAULT 0, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS lost_media ( @@ -66,6 +70,17 @@ def init_db(): channel_name TEXT PRIMARY KEY ); """) + + # Migration: Add is_live if it doesn't exist + try: + conn.execute("ALTER TABLE videos ADD COLUMN is_live BOOLEAN DEFAULT 0") + except: pass + + # Migration: Add published if it doesn't exist + try: + conn.execute("ALTER TABLE videos ADD COLUMN published TEXT") + except: pass + conn.commit() # Retry loop for DB initialization to prevent crash on SMB lock @@ -95,6 +110,48 @@ def log(msg): if len(log_buffer) > 1000: log_buffer.pop(0) +def translate_ta_path(ta_path): + """ + Translates a path from TubeArchivist's internal filesystem (usually /youtube/...) + to the container's /app/source mount. + """ + if not ta_path: return None + p = Path(ta_path) + parts = list(p.parts) + + # TA internal paths are often /youtube/Channel/Video.mp4 + # parts[0] = '/', parts[1] = 'youtube', parts[2] = 'Channel'... + if len(parts) > 2 and parts[0] == '/': + # Strip the / and the first directory (youtube or media) + # and join with our SOURCE_DIR + relative_path_parts = parts[2:] + relative_path = Path(*relative_path_parts) + translated = SOURCE_DIR / relative_path + return translated + + return p + +def translate_host_path(host_path): + """ + Translates a path from the Host filesystem (e.g. /mnt/user/tubearchives/bp/...) + to the container's /app/source mount. + """ + if not host_path: return None + host_path_str = str(host_path) + host_prefix = "/mnt/user/tubearchives/bp" + + if host_path_str.startswith(host_prefix): + rel = host_path_str[len(host_prefix):].replace("/", "", 1) + return SOURCE_DIR / rel + + # Generic fallback: if it starts with the configured HOST_SOURCE_BASE + host_base_str = str(HOST_SOURCE_BASE) + if host_path_str.startswith(host_base_str): + rel = host_path_str[len(host_base_str):].replace("/", "", 1) + return SOURCE_DIR / rel + + return Path(host_path) + def tlog(msg): """Logs a message to the transcode log buffer.""" print(f"[TRANSCODE] {msg}", flush=True) @@ -103,6 +160,12 @@ def tlog(msg): if len(transcode_log_buffer) > 500: transcode_log_buffer.pop(0) +# Helper to check if file is video +def is_video(f): + if isinstance(f, str): + f = Path(f) + return f.suffix.lower() in ['.mp4', '.mkv', '.webm', '.mov', '.avi'] + def detect_encoder(): """Detect best available hardware encoder.""" import subprocess @@ -319,7 +382,9 @@ def fetch_all_metadata(): video_map[vid_id] = { "title": title, "channel_name": channel_name, - "published": published + "published": published, + "is_live": video.get("vid_type") == "streams", + "filesystem_path": video.get("path") or video.get("filesystem_path") } # Check pagination to see if we are done @@ -343,53 +408,55 @@ def fetch_all_metadata(): def cleanup_old_folders(): """ - Scans TARGET_DIR for folders containing '+00:00'. - Safely deletes them ONLY if they contain no real files (only symlinks or empty). + Scans both TARGET_DIR and HIDDEN_DIR for empty or orphaned folders. + Safely deletes them if they contain no real files. """ - log("๐Ÿงน Starting cleanup. Scanning ONLY for folders containing '+00:00'...") + log("๐Ÿงน Starting aggressive cleanup of empty folders...") cleaned_count = 0 - skipped_count = 0 - if not TARGET_DIR.exists(): - return - - # Walk top-down - for channel_dir in TARGET_DIR.iterdir(): - if not channel_dir.is_dir(): + for root in [TARGET_DIR, HIDDEN_DIR]: + if not root.exists(): continue - for video_dir in channel_dir.iterdir(): - if not video_dir.is_dir(): + # Walk top-down: Channels + for channel_dir in root.iterdir(): + if not channel_dir.is_dir(): continue - if "+00:00" in video_dir.name: - # Check safety + # Videos + for video_dir in list(channel_dir.iterdir()): # List to allow removal + if not video_dir.is_dir(): + continue + + # Check if it contains any real files safe_to_delete = True - reason = "" - for item in video_dir.iterdir(): if not item.is_symlink(): - # Found a real file! Unsafe! safe_to_delete = False - reason = "Contains real files" break if safe_to_delete: try: # Remove all symlinks first - for item in video_dir.iterdir(): + for item in list(video_dir.iterdir()): item.unlink() - # Remove directory + # Remove video directory video_dir.rmdir() - log(f" [DELETED] {video_dir.name}") + log(f" [DELETED VIDEO] {video_dir.name}") cleaned_count += 1 except Exception as e: - log(f" โŒ Failed to delete {video_dir.name}: {e}") - else: - log(f" โš ๏ธ SKIPPING {video_dir.name} - {reason}") - skipped_count += 1 + pass # Likely not empty + + # After cleaning videos, try to clean the channel dir itself if empty + try: + if channel_dir.exists() and not any(channel_dir.iterdir()): + channel_dir.rmdir() + log(f" [DELETED CHANNEL] {channel_dir.name}") + cleaned_count += 1 + except Exception: + pass - log(f"๐Ÿงน Cleanup complete. Removed: {cleaned_count}, Skipped: {skipped_count}") + log(f"๐Ÿงน Cleanup complete. Removed {cleaned_count} empty/orphaned directories.") def check_orphaned_links(): """ @@ -504,9 +571,12 @@ def scan_for_unindexed_videos(): "lost": [] } - # Helper to check if file is video - def is_video(f): - return f.suffix.lower() in ['.mp4', '.mkv', '.webm', '.mov'] + results = { + "unindexed": [], + "redundant": [], + "rescue": [], + "lost": [] + } # --- Scan SOURCE_DIR (Standard Orphan Check) --- if SOURCE_DIR.exists(): @@ -767,109 +837,101 @@ def process_videos(): if not channel_path.is_dir(): continue for video_file in channel_path.glob("*.*"): - video_id = video_file.stem + if not is_video(video_file): + continue + + # Robust ID Extraction + video_id = extract_id_from_filename(video_file.name) + if not video_id: + # Fallback for old simple-name format + video_id = video_file.stem # Lookup in local map meta = video_map.get(video_id) if not meta: continue - sanitized_channel_name = sanitize(meta["channel_name"]) - - # Determine target root - is_hidden = meta["channel_name"] in hidden_channels - target_root = HIDDEN_DIR if is_hidden else TARGET_DIR - other_root = TARGET_DIR if is_hidden else HIDDEN_DIR - - # Check if channel exists in the WRONG place and MOVE it (Migration/Toggle) - wrong_channel_dir = other_root / sanitized_channel_name - correct_channel_dir = target_root / sanitized_channel_name - - # DEBUG LOGGING (Temporary) - if meta["channel_name"] in hidden_channels: - log(f"DEBUG: Checking {meta['channel_name']} (Hidden). Wrong Dir: {wrong_channel_dir}, Exists? {wrong_channel_dir.exists()}") - - - if wrong_channel_dir.exists(): - try: - # If destination already exists, we have a conflict. - # Strategy: Merge move? - # Simplest robust way: - # 1. Ensure dest exists - # 2. Move contents? - # Or just shutil.move(src, dst) which works if dst doesn't exist. - if not correct_channel_dir.exists(): - shutil.move(str(wrong_channel_dir), str(correct_channel_dir)) - log(f" [MOVE] Moved {sanitized_channel_name} to {target_root.name} (Status Change)") - else: - # Destination exists. We must merge. - # Move items one by one. - for item in wrong_channel_dir.iterdir(): - dest_item = correct_channel_dir / item.name - if not dest_item.exists(): - shutil.move(str(item), str(dest_item)) - else: - # Conflict. If it's a folder, we could recurse, but let's just log warning and skip? - # If it's a file/symlink, we skip (it will be regenerated/verified later by the loop) - pass - - # Now remove the empty source dir - try: - wrong_channel_dir.rmdir() - except OSError: - log(f" โš ๏ธ Could not remove old dir {wrong_channel_dir} (not empty?)") - - except Exception as e: - log(f" โŒ Failed to move {sanitized_channel_name} from old location: {e}") - - channel_dir = target_root / sanitized_channel_name - channel_dir.mkdir(parents=True, exist_ok=True) - sanitized_title = sanitize(meta["title"]) - folder_name = f"{meta['published']} - {sanitized_title}" - video_dir = channel_dir / folder_name - video_dir.mkdir(parents=True, exist_ok=True) - actual_file = next(channel_path.glob(f"{video_id}.*"), None) - if not actual_file: - continue - host_path_root = Path("/mnt/user/tubearchives/bp") - host_source_path = host_path_root / actual_file.relative_to(SOURCE_DIR) - dest_file = video_dir / f"video{actual_file.suffix}" - try: - if dest_file.exists(): - if dest_file.is_symlink(): - current_target = Path(os.readlink(dest_file)) - if current_target.resolve() != host_source_path.resolve(): - dest_file.unlink() - os.symlink(host_source_path, dest_file) - log(f" [FIX] Relinked: {folder_name}") - new_links += 1 + sanitized_channel_name = sanitize(meta["channel_name"]) + is_hidden = meta["channel_name"] in hidden_channels + target_root = HIDDEN_DIR if is_hidden else TARGET_DIR + other_root = TARGET_DIR if is_hidden else HIDDEN_DIR + + # Migration Logic + wrong_channel_dir = other_root / sanitized_channel_name + correct_channel_dir = target_root / sanitized_channel_name + + if wrong_channel_dir.exists(): + try: + if not correct_channel_dir.exists(): + shutil.move(str(wrong_channel_dir), str(correct_channel_dir)) + log(f" [MOVE] Migrated {sanitized_channel_name} to {target_root.name}") else: - verified_links += 1 + for item in list(wrong_channel_dir.iterdir()): + dest_item = correct_channel_dir / item.name + if not dest_item.exists(): + shutil.move(str(item), str(dest_item)) + try: + wrong_channel_dir.rmdir() + except OSError: + pass + except Exception as e: + log(f" โŒ Migration error for {sanitized_channel_name}: {e}") + + # Folder Creation (Delay until link check) + channel_dir = target_root / sanitized_channel_name + + # Stream Organization + if meta.get("is_live"): + channel_dir = channel_dir / "#streams" + + sanitized_title = sanitize(meta["title"]) + folder_name = f"{meta['published']} - {sanitized_title}" + video_dir = channel_dir / folder_name + + host_path_root = HOST_SOURCE_BASE + host_source_path = host_path_root / video_file.relative_to(SOURCE_DIR) + dest_file = video_dir / f"video{video_file.suffix}" + + try: + link_success = False + if dest_file.exists(): + if dest_file.is_symlink(): + current_target = Path(os.readlink(dest_file)) + if current_target.resolve() != host_source_path.resolve(): + dest_file.unlink() + os.symlink(host_source_path, dest_file) + log(f" [FIX] Relinked: {folder_name}") + new_links += 1 + link_success = True + else: + verified_links += 1 + link_success = True else: - # It's a file or something else, replace it? No, unsafe. - pass - else: - os.symlink(host_source_path, dest_file) - log(f" [NEW] Linked: {folder_name}") - new_links += 1 - except Exception: - pass - - # Store in database - conn.execute(""" - INSERT OR REPLACE INTO videos - (video_id, title, channel, published, symlink, status) - VALUES (?, ?, ?, ?, ?, 'linked') - """, (video_id, meta["title"], meta["channel_name"], - meta["published"], str(dest_file))) - - processed_videos.append({ - "video_id": video_id, - "title": meta["title"], - "channel": meta["channel_name"], - "published": meta["published"], - "symlink": str(dest_file) - }) + # Create directories ONLY NOW + channel_dir.mkdir(parents=True, exist_ok=True) + video_dir.mkdir(parents=True, exist_ok=True) + os.symlink(host_source_path, dest_file) + log(f" [NEW] Linked: {folder_name}") + new_links += 1 + link_success = True + except Exception as e: + log(f" โŒ Link error for {folder_name}: {e}") + + # Store in database + conn.execute(""" + INSERT OR REPLACE INTO videos + (video_id, title, channel, published, symlink, is_live, status) + VALUES (?, ?, ?, ?, ?, ?, 'linked') + """, (video_id, meta["title"], meta["channel_name"], + meta["published"], str(dest_file), 1 if meta.get("is_live") else 0)) + + processed_videos.append({ + "video_id": video_id, + "title": meta["title"], + "channel": meta["channel_name"], + "published": meta["published"], + "symlink": str(dest_file) + }) except Exception as e: conn.rollback() return str(e) @@ -918,22 +980,44 @@ def check_auth(username, password): """Checks whether a username/password combination is valid.""" return username == UI_USERNAME and password == UI_PASSWORD -def authenticate(): - """Sends a 401 response that enables basic auth""" - return Response( - 'Could not verify your access level for that URL.\n' - 'You have to login with proper credentials', 401, - {'WWW-Authenticate': 'Basic realm="Login Required"'}) - def requires_auth(f): @wraps(f) def decorated(*args, **kwargs): - auth = request.authorization - if not auth or not check_auth(auth.username, auth.password): - return authenticate() + if not session.get('logged_in'): + if request.path.startswith('/api/'): + return jsonify({"error": "Unauthorized"}), 401 + return redirect('/login') return f(*args, **kwargs) return decorated +@app.route("/api/auth/login", methods=["POST"]) +def api_login(): + data = request.json + username = data.get("username") + password = data.get("password") + + if check_auth(username, password): + session['logged_in'] = True + session['username'] = username + return jsonify({"success": True}) + return jsonify({"error": "Invalid credentials"}), 401 + +@app.route("/api/auth/logout", methods=["POST"]) +def api_logout(): + session.clear() + return jsonify({"success": True}) + +@app.route("/api/auth/status") +def api_auth_status(): + return jsonify({ + "logged_in": session.get('logged_in', False), + "username": session.get('username') + }) + +@app.route("/login") +def login_page(): + return send_from_directory(app.static_folder, 'login/index.html') + @app.route("/") @requires_auth def index(): @@ -1131,6 +1215,186 @@ def api_recovery_start(): "status": "completed" if success else "failed" }) +@app.route("/api/recovery/delete-batch", methods=["POST"]) +@requires_auth +def api_recovery_delete_batch(): + data = request.json + paths = data.get("filepaths", []) + destruct = data.get("destruct_mode", False) + + success_count = 0 + fail_count = 0 + errors = [] + + log(f"๐Ÿ”ฅ Batch Delete started. Items: {len(paths)}, Destruct: {destruct}") + + # Refresh metadata for destruct mode + video_map = {} + if destruct: + # Optimization: only fetch if destruct is on + video_map = fetch_all_metadata() + + for path in paths: + try: + # 1. Destruct Source if enabled + p = Path(path) + if destruct: + source_deleted = False + source_path = None + + # --- Method A: Symlink Resolution (Highest reliability for library files) --- + if os.path.islink(p): + try: + target_raw = os.readlink(p) + log(f" [DESTRUCT] Symlink target: {target_raw}") + source_path = translate_host_path(target_raw) + log(f" [DESTRUCT] Translated source: {source_path}") + except Exception as eread: + log(f" [DESTRUCT] Warning: Could not read symlink {p}: {eread}") + + # --- Method B: Database/TA Metadata Fallback --- + if not source_path or not source_path.exists(): + # Try to find video_id from DB + vid_id = None + with get_db() as conn: + row = conn.execute("SELECT video_id FROM videos WHERE symlink = ?", (path,)).fetchone() + if row: + vid_id = row['video_id'] + + if not vid_id: + vid_id = extract_id_from_filename(p.name) + + if vid_id: + meta = video_map.get(vid_id) + if meta: + raw_ta_path = meta.get('filesystem_path') + if raw_ta_path: + source_path = translate_ta_path(raw_ta_path) + log(f" [DESTRUCT] API Fallback Path: {source_path}") + + # --- Method C: Recursive Search Fallback (Safety net) --- + if not source_path or not source_path.exists(): + log(f" [DESTRUCT] Final fallback: recursive search for ID {vid_id or 'unknown'}") + if vid_id: + video_extensions = {".mp4", ".mkv", ".webm", ".avi", ".mov"} + # Search SOURCE_DIR but skip 'tcconf' and only match video files + for candidate in SOURCE_DIR.rglob(f"*{vid_id}*"): + # Safety check: Skip tcconf directory and non-video extensions + if "tcconf" in candidate.parts: + continue + if candidate.suffix.lower() not in video_extensions: + continue + + if candidate.is_file(): + source_path = candidate + log(f" [DESTRUCT] Search found match: {source_path}") + break + + # --- Execution: Delete the identified source --- + if source_path and source_path.exists(): + try: + source_path.unlink() + log(f"โ˜ข๏ธ [DESTRUCT] Deleted source: {source_path}") + source_deleted = True + except Exception as se: + log(f"โŒ [DESTRUCT] Failed to delete source {source_path}: {se}") + raise Exception(f"Source deletion failed: {se}") + else: + log(f"โš ๏ธ [DESTRUCT] Source file NOT FOUND (Tried symlink resolution and API lookup)") + + if not source_deleted: + # Log IDs to help debug if it still fails + vid_id_debug = "?" + with get_db() as conn: + row = conn.execute("SELECT video_id FROM videos WHERE symlink = ?", (path,)).fetchone() + if row: vid_id_debug = row['video_id'] + log(f"โš ๏ธ [DESTRUCT] Source file not found for: {path} (ID: {vid_id_debug})") + + # 2. Delete Target (Use lexists so we can delete broken symlinks!) + if os.path.lexists(p): + if p.is_dir(): + shutil.rmtree(p) + else: + p.unlink() + log(f"๐Ÿ—‘๏ธ Deleted target: {path}") + + # 3. Synchronize Database (Remove orphaned record) + with get_db() as conn: + conn.execute("DELETE FROM videos WHERE symlink = ?", (path,)) + conn.commit() + log(f"๐Ÿ’พ [SYNC] Removed DB record for: {path}") + + # 4. Cleanup empty parent + parent = p.parent + if parent != Path(TARGET_DIR) and parent != Path(HIDDEN_DIR): + if parent.exists() and not any(parent.iterdir()): + try: + parent.rmdir() + log(f"๐Ÿงน [CLEANUP] Removed empty folder: {parent}") + except: pass + else: + log(f"โ“ Target path does not exist (skipping): {path}") + + success_count += 1 + except Exception as e: + err_msg = str(e) + log(f"โŒ Failed to delete {path}: {err_msg}") + fail_count += 1 + if err_msg not in errors: + errors.append(err_msg) + + return jsonify({ + "success_count": success_count, + "fail_count": fail_count, + "errors": errors[:5] + }) + +@app.route("/api/system/check-permissions", methods=["GET"]) +@requires_auth +def api_check_permissions(): + results = {} + test_dirs = [ + ("source", SOURCE_DIR), + ("target", TARGET_DIR), + ("hidden", HIDDEN_DIR), + ("data", DATA_DIR) + ] + + log("๐Ÿ” Running System Permission Check...") + + for name, path in test_dirs: + if not path: + results[name] = {"status": "unset", "writeable": False} + continue + + p = Path(path) + if not p.exists(): + results[name] = {"status": "missing", "writeable": False, "message": "Directory does not exist"} + log(f" โŒ {name} ({path}): MISSING") + continue + + test_file = p / f".write_test_{os.getpid()}" + try: + # Try to write + log(f" ๐Ÿงช Testing write on {name}...") + if test_file.exists(): test_file.unlink() # Cleanup old failure + with open(test_file, "w") as f: + f.write("test") + # Try to delete + test_file.unlink() + + results[name] = {"status": "ok", "writeable": True} + log(f" โœ… {name} ({path}): WRITEABLE") + except Exception as e: + msg = str(e) + results[name] = {"status": "error", "writeable": False, "message": msg} + log(f" โŒ {name} ({path}): READ-ONLY or PERMISSION DENIED - {msg}") + # Identify if it is literally "Read-only file system" + if "Read-only file system" in msg: + log(f" ๐Ÿšจ POSITIVE R/O MOUNT DETECTED for {name}") + + return jsonify(results) + @app.route("/api/recovery/delete", methods=["POST"]) @requires_auth def api_recovery_delete(): @@ -1173,6 +1437,8 @@ def api_recovery_delete(): if vid_id: with get_db() as conn: conn.execute("DELETE FROM lost_media WHERE video_id = ?", (vid_id,)) + # Also remove from main videos table if present + conn.execute("DELETE FROM videos WHERE symlink = ?", (filepath,)) conn.commit() log(f"๐Ÿ—‘๏ธ Deleted file: {filepath}") @@ -1181,6 +1447,43 @@ def api_recovery_delete(): log(f"โŒ Delete failed: {e}") return jsonify({"error": str(e)}), 500 +@app.route("/api/channels", methods=["GET"]) +@requires_auth +def api_get_channels(): + with get_db() as conn: + rows = conn.execute("SELECT DISTINCT channel FROM videos WHERE channel IS NOT NULL ORDER BY channel ASC").fetchall() + channels = [row['channel'] for row in rows if row['channel']] + return jsonify(channels) + +@app.route("/api/channels/videos", methods=["GET"]) +@requires_auth +def api_get_channel_videos(): + channel_name = request.args.get('channel') + if not channel_name: + return jsonify({"error": "No channel name provided"}), 400 + + # Refresh metadata to get filesystem paths + video_map = fetch_all_metadata() + + with get_db() as conn: + rows = conn.execute("SELECT video_id, title, symlink, is_live FROM videos WHERE channel = ? ORDER BY published DESC", (channel_name,)).fetchall() + + videos = [] + for row in rows: + vid_id = row['video_id'] + meta = video_map.get(vid_id, {}) + videos.append({ + "video_id": vid_id, + "title": row['title'], + "path": row['symlink'], + "filename": Path(row['symlink']).name if row['symlink'] else meta.get('title'), + "source_path": meta.get('filesystem_path'), + "ta_source": meta.get('channel_name', channel_name), + "is_live": bool(row['is_live']) if 'is_live' in row.keys() else False + }) + + return jsonify(videos) + @app.route('/api/recovery/force', methods=['POST']) @requires_auth def api_recovery_force(): diff --git a/ui/src/components/Dashboard.svelte b/ui/src/components/Dashboard.svelte index c694fc0..29181ae 100644 --- a/ui/src/components/Dashboard.svelte +++ b/ui/src/components/Dashboard.svelte @@ -24,6 +24,10 @@ async function fetchData() { try { const res = await fetch("/api/status"); + if (res.status === 401) { + window.location.href = "/login"; + return; + } if (!res.ok) throw new Error("Failed to fetch status"); const data = await res.json(); diff --git a/ui/src/components/RecoveryModal.svelte b/ui/src/components/RecoveryModal.svelte index 23a975d..891224b 100644 --- a/ui/src/components/RecoveryModal.svelte +++ b/ui/src/components/RecoveryModal.svelte @@ -2,11 +2,38 @@ import { createEventDispatcher, onMount, onDestroy } from "svelte"; const dispatch = createEventDispatcher(); - let activeTab: "unindexed" | "rescue" | "redundant" | "lost" = "unindexed"; + let activeTab: "unindexed" | "rescue" | "redundant" | "lost" | "channels" = "unindexed"; let scanning = false; let status = "idle"; let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] }; let pollInterval: ReturnType; + + // State for Destruct Mode & Multi-selection + let destructMode = false; + let selectedPaths = new Set(); + + // Channel specific state + let channels: string[] = []; + let selectedChannel = ""; + let channelVideos: any[] = []; + let loadingChannels = false; + let searchingVideos = false; + + // Permissions state + let permissions: any = null; + let loadingPermissions = false; + + async function checkPermissions() { + loadingPermissions = true; + try { + const res = await fetch("/api/system/check-permissions"); + permissions = await res.json(); + } catch (e) { + console.error("Failed to check permissions", e); + } finally { + loadingPermissions = false; + } + } async function startScan() { scanning = true; @@ -43,12 +70,38 @@ }, 2000); } + async function fetchChannels() { + loadingChannels = true; + try { + const res = await fetch("/api/channels"); + channels = await res.json(); + } catch (e) { + console.error("Failed to fetch channels", e); + } finally { + loadingChannels = false; + } + } + + async function fetchChannelVideos(channel: string) { + if (!channel) return; + searchingVideos = true; + selectedChannel = channel; + selectedPaths = new Set(); + try { + const res = await fetch(`/api/channels/videos?channel=${encodeURIComponent(channel)}`); + channelVideos = await res.json(); + } catch (e) { + console.error("Failed to fetch channel videos", e); + } finally { + searchingVideos = false; + } + } + onDestroy(() => { if (pollInterval) clearInterval(pollInterval); }); async function recoverFile(path: string, isBatch = false) { - // Implementation mirrors existing JS logic if (!isBatch && !confirm("Recover this file?")) return; try { const res = await fetch("/api/recovery/start", { @@ -59,112 +112,350 @@ const d = await res.json(); if (!isBatch) { alert(d.message); - startScan(); - } // Refresh + if (activeTab === "channels") fetchChannelVideos(selectedChannel); + else startScan(); + } } catch (e) { alert(e); } } async function deleteFile(path: string) { - if (!confirm("Delete file? This cannot be undone.")) return; + let msg = "Delete file? This cannot be undone."; + if (destructMode) { + msg = "โ˜ข๏ธ DESTRUCT MODE ACTIVE โ˜ข๏ธ\n\nThis will delete BOTH the target folder AND the source file in your archive.\n\nAre you absolutely sure?"; + } + + if (!confirm(msg)) return; + if (destructMode && !confirm("FINAL WARNING: This is IRREVERSIBLE. Delete source file now?")) return; + try { - const res = await fetch("/api/recovery/delete", { + const res = await fetch("/api/recovery/delete-batch", { method: "POST", - body: JSON.stringify({ filepath: path }), + body: JSON.stringify({ + filepaths: [path], + destruct_mode: destructMode + }), headers: { "Content-Type": "application/json" }, }); const d = await res.json(); - if (d.success) { + if (d.success_count > 0) { alert("Deleted."); - startScan(); - } else alert("Error: " + d.error); + if (activeTab === "channels") fetchChannelVideos(selectedChannel); + else startScan(); + } else { + const err = d.errors?.[0] || "Unknown error"; + alert(`Error deleting file: ${err}`); + } } catch (e) { alert(e); } } + + async function deleteSelected() { + if (selectedPaths.size === 0) return; + + let msg = `Delete ${selectedPaths.size} selected items?`; + if (destructMode) { + msg = `โ˜ข๏ธ DESTRUCT MODE ACTIVE โ˜ข๏ธ\n\nYou are about to delete ${selectedPaths.size} items from BOTH Target and Source.\n\nAre you sure you want to proceed?`; + } + + if (!confirm(msg)) return; + if (destructMode && !confirm("FINAL WARNING: This will permanently delete SOURCE FILES. Continue?")) return; + + try { + const res = await fetch("/api/recovery/delete-batch", { + method: "POST", + body: JSON.stringify({ + filepaths: Array.from(selectedPaths), + destruct_mode: destructMode + }), + headers: { "Content-Type": "application/json" }, + }); + const d = await res.json(); + + if (d.fail_count > 0) { + const firstErr = d.errors?.[0] || "Check backend logs."; + alert(`Batch partially failed.\nSuccess: ${d.success_count}\nFailed: ${d.fail_count}\n\nFirst error: ${firstErr}`); + } else { + alert(`Batch complete. Deleted ${d.success_count} items.`); + } + + selectedPaths = new Set(); + if (activeTab === "channels") fetchChannelVideos(selectedChannel); + else startScan(); + } catch (e) { + alert("Batch delete failed: " + e); + } + } + + function toggleSelect(path: string) { + if (selectedPaths.has(path)) { + selectedPaths.delete(path); + } else { + selectedPaths.add(path); + } + selectedPaths = selectedPaths; + } + + function toggleAll() { + const items = activeTab === "channels" ? channelVideos : (results[activeTab] || []); + if (selectedPaths.size === items.length && items.length > 0) { + selectedPaths = new Set(); + } else { + selectedPaths = new Set(items.map((i: any) => i.path)); + } + } + + $: allSelected = (activeTab === "channels" ? channelVideos : results[activeTab])?.length > 0 && selectedPaths.size === (activeTab === "channels" ? channelVideos : results[activeTab])?.length; + + $: sourceRO = permissions?.source?.writeable === false; + $: targetRO = permissions?.target?.writeable === false; + + onMount(() => { + fetchChannels(); + checkPermissions(); + });

Advanced Recovery + {#if destructMode} + DESTRUCT MODE ACTIVE + {/if}

+ + {#if sourceRO || targetRO} +
+ +
+ Filesystem Alert: + {#if sourceRO && targetRO} + Both Source AND Target directories are currently READ-ONLY. +
Errors: {permissions?.source?.message} | {permissions?.target?.message}
+ {:else if sourceRO} + Source archive is READ-ONLY. Destruct Mode will fail. +
Error: {permissions?.source?.message}
+ {:else} + Target library is READ-ONLY. Symlink cleanup will fail. +
Error: {permissions?.target?.message}
+ {/if} +
+ +
+ {/if} + -
- -
Status: {status}
+
+
+ + + {#if selectedPaths.size > 0} + + {/if} +
+ +
+ + + +
Status: {status}
+
-
- {#each ["unindexed", "rescue", "redundant", "lost"] as tab} +
+ {#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab} {/each}
- {#if scanning && (!results[activeTab] || results[activeTab].length === 0)} + {#if activeTab === "channels"} +
+ +
+
+ + +
+ +
+ + +
+ {#if searchingVideos} +
+
+ QUERYING TA DATABASE... +
+ {:else if channelVideos.length > 0} + + + + + + + + + + + {#each channelVideos as item} + + + + + + + {/each} + +
+ + Video IDTitleAction
+ toggleSelect(item.path)} + class="accent-neon-cyan" + > + {item.video_id}{item.title} + +
+ {:else} +
+ + No channel selected +
+ {/if} +
+
+ {:else if scanning && (!results[activeTab] || results[activeTab].length === 0)}
- Scanning... +
+
SYSTEM WIDE SCAN IN PROGRESS...
{:else} - - +
+ - - - + + + + {#each results[activeTab] || [] as item} - + + {item.filename || item.path} + {/each} {#if !results[activeTab]?.length} + + NO ANOMALIES DETECTED IN THIS SECTOR. + {/if} @@ -221,3 +519,28 @@ + + diff --git a/ui/src/layouts/Layout.astro b/ui/src/layouts/Layout.astro index 0f94ddb..5712307 100644 --- a/ui/src/layouts/Layout.astro +++ b/ui/src/layouts/Layout.astro @@ -70,6 +70,12 @@ const { title } = Astro.props; > Transcoding +
@@ -95,5 +101,16 @@ const { title } = Astro.props; >

TUBESORTER // SYSTEM_V2 // BUN_POWERED

+ diff --git a/ui/src/pages/login.astro b/ui/src/pages/login.astro new file mode 100644 index 0000000..ff175d3 --- /dev/null +++ b/ui/src/pages/login.astro @@ -0,0 +1,101 @@ +--- +import Layout from "../layouts/Layout.astro"; +--- + + +
+
+ +
+
+ +
+
+ +

+ ACCESS_REQUIRED +

+

+ // PLEASE_IDENTIFY_YOURSELF +

+
+ +
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+ + + +
+ +
+ +
+
+
+
+ +
Video IDFilename / PathSize/Info + + IDFilename / Target PathInfo Action
+ toggleSelect(item.path)} + class="accent-neon-cyan" + > + {item.video_id} {item.filename || item.path} {item.size_mb @@ -172,46 +463,53 @@ : item.ta_source || "-"} - {#if activeTab === "unindexed"} - - {:else if activeTab === "redundant"} - - {:else if activeTab === "lost"} - - - {:else} - - {/if} +
+ {#if activeTab === "unindexed"} + + {:else if activeTab === "redundant"} + + {:else if activeTab === "lost"} + + + {:else} + + {/if} +
No items found.