diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 0e42fc2..70c74b9 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -2,7 +2,7 @@ name: Docker Build on: push: - branches: [ "main" , "feature/ui-login-rework"] + branches: [ "main" ] paths-ignore: - 'README.md' - '.gitignore' diff --git a/README.md b/README.md index 8f9faae..6a73c23 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/Salpertio/tubesorter.git +git clone https://github.com/wander/ta-organizerr.git cd tubesorter ``` diff --git a/ta_symlink.py b/ta_symlink.py index 9aec3f2..81e3651 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, session, redirect, url_for +from flask import Flask, jsonify, render_template, request, abort, Response, send_from_directory # Load config from environment variables API_URL = os.getenv("API_URL", "http://localhost:8457/api") @@ -23,14 +23,11 @@ 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 @@ -58,7 +55,6 @@ 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 ( @@ -70,17 +66,6 @@ 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 @@ -110,48 +95,6 @@ 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) @@ -160,12 +103,6 @@ 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 @@ -382,9 +319,7 @@ def fetch_all_metadata(): video_map[vid_id] = { "title": title, "channel_name": channel_name, - "published": published, - "is_live": video.get("vid_type") == "streams", - "filesystem_path": video.get("path") or video.get("filesystem_path") + "published": published } # Check pagination to see if we are done @@ -408,55 +343,53 @@ def fetch_all_metadata(): def cleanup_old_folders(): """ - Scans both TARGET_DIR and HIDDEN_DIR for empty or orphaned folders. - Safely deletes them if they contain no real files. + Scans TARGET_DIR for folders containing '+00:00'. + Safely deletes them ONLY if they contain no real files (only symlinks or empty). """ - log("๐Ÿงน Starting aggressive cleanup of empty folders...") + log("๐Ÿงน Starting cleanup. Scanning ONLY for folders containing '+00:00'...") cleaned_count = 0 + skipped_count = 0 - for root in [TARGET_DIR, HIDDEN_DIR]: - if not root.exists(): + if not TARGET_DIR.exists(): + return + + # Walk top-down + for channel_dir in TARGET_DIR.iterdir(): + if not channel_dir.is_dir(): continue - # Walk top-down: Channels - for channel_dir in root.iterdir(): - if not channel_dir.is_dir(): + for video_dir in channel_dir.iterdir(): + if not video_dir.is_dir(): continue - # 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 + if "+00:00" in video_dir.name: + # Check safety 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 list(video_dir.iterdir()): + for item in video_dir.iterdir(): item.unlink() - # Remove video directory + # Remove directory video_dir.rmdir() - log(f" [DELETED VIDEO] {video_dir.name}") + log(f" [DELETED] {video_dir.name}") cleaned_count += 1 except Exception as e: - 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" โŒ Failed to delete {video_dir.name}: {e}") + else: + log(f" โš ๏ธ SKIPPING {video_dir.name} - {reason}") + skipped_count += 1 - log(f"๐Ÿงน Cleanup complete. Removed {cleaned_count} empty/orphaned directories.") + log(f"๐Ÿงน Cleanup complete. Removed: {cleaned_count}, Skipped: {skipped_count}") def check_orphaned_links(): """ @@ -571,12 +504,9 @@ def scan_for_unindexed_videos(): "lost": [] } - results = { - "unindexed": [], - "redundant": [], - "rescue": [], - "lost": [] - } + # Helper to check if file is video + def is_video(f): + return f.suffix.lower() in ['.mp4', '.mkv', '.webm', '.mov'] # --- Scan SOURCE_DIR (Standard Orphan Check) --- if SOURCE_DIR.exists(): @@ -837,101 +767,109 @@ def process_videos(): if not channel_path.is_dir(): continue for video_file in channel_path.glob("*.*"): - 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 + 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"]) - 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: - 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}") + + # 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 - # 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}" - + # 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: - 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 + # 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: - # 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 + # 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" โŒ 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) - }) + 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 + else: + verified_links += 1 + 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) + }) except Exception as e: conn.rollback() return str(e) @@ -980,44 +918,22 @@ 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): - if not session.get('logged_in'): - if request.path.startswith('/api/'): - return jsonify({"error": "Unauthorized"}), 401 - return redirect('/login') + auth = request.authorization + if not auth or not check_auth(auth.username, auth.password): + return authenticate() 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(): @@ -1215,186 +1131,6 @@ 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(): @@ -1437,8 +1173,6 @@ 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}") @@ -1447,43 +1181,6 @@ 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 29181ae..c694fc0 100644 --- a/ui/src/components/Dashboard.svelte +++ b/ui/src/components/Dashboard.svelte @@ -24,10 +24,6 @@ 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 891224b..23a975d 100644 --- a/ui/src/components/RecoveryModal.svelte +++ b/ui/src/components/RecoveryModal.svelte @@ -2,38 +2,11 @@ import { createEventDispatcher, onMount, onDestroy } from "svelte"; const dispatch = createEventDispatcher(); - let activeTab: "unindexed" | "rescue" | "redundant" | "lost" | "channels" = "unindexed"; + let activeTab: "unindexed" | "rescue" | "redundant" | "lost" = "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; @@ -70,38 +43,12 @@ }, 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", { @@ -112,350 +59,112 @@ const d = await res.json(); if (!isBatch) { alert(d.message); - if (activeTab === "channels") fetchChannelVideos(selectedChannel); - else startScan(); - } + startScan(); + } // Refresh } catch (e) { alert(e); } } async function deleteFile(path: string) { - 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; - + if (!confirm("Delete file? This cannot be undone.")) return; try { - const res = await fetch("/api/recovery/delete-batch", { + const res = await fetch("/api/recovery/delete", { method: "POST", - body: JSON.stringify({ - filepaths: [path], - destruct_mode: destructMode - }), + body: JSON.stringify({ filepath: path }), headers: { "Content-Type": "application/json" }, }); const d = await res.json(); - if (d.success_count > 0) { + if (d.success) { alert("Deleted."); - if (activeTab === "channels") fetchChannelVideos(selectedChannel); - else startScan(); - } else { - const err = d.errors?.[0] || "Unknown error"; - alert(`Error deleting file: ${err}`); - } + startScan(); + } else alert("Error: " + d.error); } 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} - -
-
- - - {#if selectedPaths.size > 0} - - {/if} -
- -
- - - -
Status: {status}
-
+
+ +
Status: {status}
-
- {#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab} +
+ {#each ["unindexed", "rescue", "redundant", "lost"] as tab} {/each}
- {#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)} + {#if scanning && (!results[activeTab] || results[activeTab].length === 0)}
-
-
SYSTEM WIDE SCAN IN PROGRESS...
+ Scanning...
{:else} - - +
+ - - - - + + + {#each results[activeTab] || [] as item} - - {item.filename || item.path} {/each} {#if !results[activeTab]?.length} No items found. {/if} @@ -519,28 +221,3 @@ - - diff --git a/ui/src/layouts/Layout.astro b/ui/src/layouts/Layout.astro index 5712307..0f94ddb 100644 --- a/ui/src/layouts/Layout.astro +++ b/ui/src/layouts/Layout.astro @@ -70,12 +70,6 @@ const { title } = Astro.props; > Transcoding -
@@ -101,16 +95,5 @@ const { title } = Astro.props; >

TUBESORTER // SYSTEM_V2 // BUN_POWERED

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

- ACCESS_REQUIRED -

-

- // PLEASE_IDENTIFY_YOURSELF -

-
- -
-
-
- -
- - - - -
-
-
- -
- - - - -
-
-
- - - -
- -
- -
-
-
-
- -
- - IDFilename / Target PathInfoVideo IDFilename / PathSize/Info Action
- toggleSelect(item.path)} - class="accent-neon-cyan" - > - {item.video_id} - {item.filename || item.path} - {item.size_mb @@ -463,53 +172,46 @@ : 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 ANOMALIES DETECTED IN THIS SECTOR. -