From 394c27401d2c2b2d0b6abecffb464642cdbf4a47 Mon Sep 17 00:00:00 2001 From: wander Date: Sun, 8 Mar 2026 04:00:14 -0400 Subject: [PATCH 01/19] feat: implement session-based authentication and modern login UI --- ta_symlink.py | 45 +++++++++---- ui/src/components/Dashboard.svelte | 4 ++ ui/src/layouts/Layout.astro | 17 +++++ ui/src/pages/login.astro | 101 +++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 ui/src/pages/login.astro diff --git a/ta_symlink.py b/ta_symlink.py index 81e3651..21fd233 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") @@ -28,6 +28,7 @@ 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 @@ -918,22 +919,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(): 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/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 +

+
+ +
+
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+ + + +
+ +
+
+
+
+
+
+ + From 8a9f8fbb35699fc2cdf522b7435f0aba9bc660c6 Mon Sep 17 00:00:00 2001 From: wander Date: Sun, 8 Mar 2026 04:02:03 -0400 Subject: [PATCH 02/19] Update .gitea/workflows/docker-build.yml --- .gitea/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From dd25df4bdc14128b396bfc445f19d2b0f4ed9a26 Mon Sep 17 00:00:00 2001 From: wander Date: Sun, 8 Mar 2026 04:29:37 -0400 Subject: [PATCH 03/19] fix: prevent orphaned folder creation and improve cleanup --- ta_symlink.py | 221 +++++++++++++++++++++++--------------------------- 1 file changed, 101 insertions(+), 120 deletions(-) diff --git a/ta_symlink.py b/ta_symlink.py index 21fd233..133f819 100644 --- a/ta_symlink.py +++ b/ta_symlink.py @@ -344,53 +344,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(): """ @@ -774,103 +776,82 @@ def process_videos(): 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 & Linking + channel_dir = target_root / sanitized_channel_name + sanitized_title = sanitize(meta["title"]) + folder_name = f"{meta['published']} - {sanitized_title}" + video_dir = channel_dir / folder_name + + # IMPORTANT: mkdir only when we are sure we have the file + # actual_file is video_file + + channel_dir.mkdir(parents=True, exist_ok=True) + video_dir.mkdir(parents=True, exist_ok=True) + + host_path_root = Path("/mnt/user/tubearchives/bp") + host_source_path = host_path_root / video_file.relative_to(SOURCE_DIR) + dest_file = video_dir / f"video{video_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) - }) + os.symlink(host_source_path, dest_file) + log(f" [NEW] Linked: {folder_name}") + new_links += 1 + 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, 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) From 29c3339c39a9dc3260bc5fd8c664a226b3f78ef3 Mon Sep 17 00:00:00 2001 From: wander Date: Sun, 8 Mar 2026 04:41:49 -0400 Subject: [PATCH 04/19] feat: implement advanced recovery destruct mode and multi-selection --- ta_symlink.py | 64 ++++++- ui/src/components/RecoveryModal.svelte | 238 +++++++++++++++++++------ 2 files changed, 242 insertions(+), 60 deletions(-) diff --git a/ta_symlink.py b/ta_symlink.py index 133f819..d614daf 100644 --- a/ta_symlink.py +++ b/ta_symlink.py @@ -320,7 +320,8 @@ def fetch_all_metadata(): video_map[vid_id] = { "title": title, "channel_name": channel_name, - "published": published + "published": published, + "filesystem_path": video.get("path") or video.get("filesystem_path") } # Check pagination to see if we are done @@ -1135,6 +1136,67 @@ 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.get_json() + filepaths = data.get('filepaths', []) + destruct_mode = data.get('destruct_mode', False) + + if not filepaths: + return jsonify({"error": "No filepaths provided"}), 400 + + results = [] + # Refresh metadata to ensure we have latest paths for destruct mode + video_map = fetch_all_metadata() if destruct_mode else {} + + for filepath in filepaths: + p = Path(filepath) + if not p.exists(): + results.append({"path": filepath, "success": False, "error": "File not found"}) + continue + + try: + vid_id = extract_id_from_filename(p.name) + + # DESTRUCT MODE: Delete source too + if destruct_mode and vid_id: + meta = video_map.get(vid_id) + if meta and meta.get('filesystem_path'): + source_path = Path(meta['filesystem_path']) + if source_path.exists(): + source_path.unlink() + log(f" [DESTRUCT] Deleted source: {source_path}") + + # Also check lost_media table + with get_db() as conn: + conn.execute("DELETE FROM lost_media WHERE video_id = ?", (vid_id,)) + conn.commit() + + # DELETE TARGET (Symlink/Resource) + p.unlink() + + # Clean up empty parent folder if it's a video folder + parent = p.parent + if parent not in [TARGET_DIR, HIDDEN_DIR, SOURCE_DIR] and parent.name != "source": + try: + if not any(parent.iterdir()): + parent.rmdir() + log(f" [CLEANUP] Removed empty dir: {parent}") + except: + pass + + results.append({"path": filepath, "success": True}) + except Exception as e: + results.append({"path": filepath, "success": False, "error": str(e)}) + log(f"❌ Failed to delete {filepath}: {e}") + + return jsonify({ + "results": results, + "success_count": len([r for r in results if r["success"]]), + "fail_count": len([r for r in results if not r["success"]]) + }) + @app.route("/api/recovery/delete", methods=["POST"]) @requires_auth def api_recovery_delete(): diff --git a/ui/src/components/RecoveryModal.svelte b/ui/src/components/RecoveryModal.svelte index 23a975d..6203a62 100644 --- a/ui/src/components/RecoveryModal.svelte +++ b/ui/src/components/RecoveryModal.svelte @@ -7,6 +7,10 @@ let status = "idle"; let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] }; let pollInterval: ReturnType; + + // New State for Destruct Mode & Multi-selection + let destructMode = false; + let selectedPaths = new Set(); async function startScan() { scanning = true; @@ -48,7 +52,6 @@ }); 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", { @@ -60,29 +63,89 @@ if (!isBatch) { alert(d.message); startScan(); - } // Refresh + } } 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); + } else alert("Error deleting file."); } 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(); + alert(`Batch complete. Success: ${d.success_count}, Failed: ${d.fail_count}`); + selectedPaths = new Set(); + 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; // Trigger Svelte reactivity + } + + function toggleAll() { + const items = results[activeTab] || []; + if (selectedPaths.size === items.length && items.length > 0) { + selectedPaths = new Set(); + } else { + selectedPaths = new Set(items.map((i: any) => i.path)); + } + } + + $: allSelected = results[activeTab]?.length > 0 && selectedPaths.size === results[activeTab]?.length;

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

-
- -
Status: {status}
+
+
+ + + {#if selectedPaths.size > 0} + + {/if} +
+ +
+ + + +
Status: {status}
+
@@ -125,7 +215,7 @@ {activeTab === tab ? 'border-neon-cyan text-neon-cyan' : 'border-transparent text-gray-500 hover:text-gray-300'}" - on:click={() => (activeTab = tab as any)} + on:click={() => { activeTab = tab as any; selectedPaths = new Set(); }} > {tab} {#if scanning && (!results[activeTab] || results[activeTab].length === 0)}
- Scanning... +
+
SYSTEM WIDE SCAN IN PROGRESS...
{:else} - - +
+ + @@ -156,15 +255,25 @@ {#each results[activeTab] || [] as item} - + + {item.filename || item.path} + {/each} {#if !results[activeTab]?.length} + + Nothing detected in this category. + {/if} @@ -221,3 +335,9 @@ + + From 62428c313b987302d342a9c3bec9dba0e753fc4b Mon Sep 17 00:00:00 2001 From: wander Date: Sun, 8 Mar 2026 04:51:42 -0400 Subject: [PATCH 05/19] feat: add channels tab to advanced recovery for mass deletion --- ta_symlink.py | 36 ++++++ ui/src/components/RecoveryModal.svelte | 146 +++++++++++++++++++++++-- 2 files changed, 171 insertions(+), 11 deletions(-) diff --git a/ta_symlink.py b/ta_symlink.py index d614daf..a6ded67 100644 --- a/ta_symlink.py +++ b/ta_symlink.py @@ -1247,6 +1247,42 @@ 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 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) + }) + + return jsonify(videos) + @app.route('/api/recovery/force', methods=['POST']) @requires_auth def api_recovery_force(): diff --git a/ui/src/components/RecoveryModal.svelte b/ui/src/components/RecoveryModal.svelte index 6203a62..684fbd0 100644 --- a/ui/src/components/RecoveryModal.svelte +++ b/ui/src/components/RecoveryModal.svelte @@ -2,7 +2,7 @@ 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: [] }; @@ -12,6 +12,13 @@ let destructMode = false; let selectedPaths = new Set(); + // Channel specific state + let channels: string[] = []; + let selectedChannel = ""; + let channelVideos: any[] = []; + let loadingChannels = false; + let searchingVideos = false; + async function startScan() { scanning = true; try { @@ -47,6 +54,33 @@ }, 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); }); @@ -62,7 +96,8 @@ const d = await res.json(); if (!isBatch) { alert(d.message); - startScan(); + if (activeTab === "channels") fetchChannelVideos(selectedChannel); + else startScan(); } } catch (e) { alert(e); @@ -91,7 +126,8 @@ const d = await res.json(); if (d.success_count > 0) { alert("Deleted."); - startScan(); + if (activeTab === "channels") fetchChannelVideos(selectedChannel); + else startScan(); } else alert("Error deleting file."); } catch (e) { alert(e); @@ -121,7 +157,8 @@ const d = await res.json(); alert(`Batch complete. Success: ${d.success_count}, Failed: ${d.fail_count}`); selectedPaths = new Set(); - startScan(); + if (activeTab === "channels") fetchChannelVideos(selectedChannel); + else startScan(); } catch (e) { alert("Batch delete failed: " + e); } @@ -137,7 +174,7 @@ } function toggleAll() { - const items = results[activeTab] || []; + const items = activeTab === "channels" ? channelVideos : (results[activeTab] || []); if (selectedPaths.size === items.length && items.length > 0) { selectedPaths = new Set(); } else { @@ -145,7 +182,11 @@ } } - $: allSelected = results[activeTab]?.length > 0 && selectedPaths.size === results[activeTab]?.length; + $: allSelected = (activeTab === "channels" ? channelVideos : results[activeTab])?.length > 0 && selectedPaths.size === (activeTab === "channels" ? channelVideos : results[activeTab])?.length; + + onMount(() => { + fetchChannels(); + });
- {#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} +
+ Fetching videos... +
+ {:else if channelVideos.length > 0} +
+ + Video ID Filename / Path Size/Info
+ toggleSelect(item.path)} + class="accent-neon-cyan" + > + {item.video_id} {item.filename || item.path} {item.size_mb @@ -172,46 +281,51 @@ : 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.
+ + + + + + + + + + {#each channelVideos as item} + + + + + + + {/each} + +
+ + Video IDTitleAction
+ toggleSelect(item.path)} + class="accent-neon-cyan" + > + {item.video_id}{item.title} + +
+ {:else} +
+ + Select a channel to view its videos. +
+ {/if} +
+
+ {:else if scanning && (!results[activeTab] || results[activeTab].length === 0)}
From 85f7a188834e8e63c4f8fa6b211e61a1461515ea Mon Sep 17 00:00:00 2001 From: wander Date: Sun, 8 Mar 2026 05:10:37 -0400 Subject: [PATCH 06/19] feat: add filesystem permissions check and improved batch deletion error handling --- ta_symlink.py | 94 +++++++------- ui/src/components/RecoveryModal.svelte | 172 ++++++++++++++++++------- 2 files changed, 170 insertions(+), 96 deletions(-) diff --git a/ta_symlink.py b/ta_symlink.py index a6ded67..633d70e 100644 --- a/ta_symlink.py +++ b/ta_symlink.py @@ -1139,62 +1139,60 @@ def api_recovery_start(): @app.route("/api/recovery/delete-batch", methods=["POST"]) @requires_auth def api_recovery_delete_batch(): - data = request.get_json() - filepaths = data.get('filepaths', []) - destruct_mode = data.get('destruct_mode', False) + data = request.json + paths = data.get("filepaths", []) + destruct = data.get("destruct_mode", False) - if not filepaths: - return jsonify({"error": "No filepaths provided"}), 400 - - results = [] - # Refresh metadata to ensure we have latest paths for destruct mode - video_map = fetch_all_metadata() if destruct_mode else {} + success_count = 0 + fail_count = 0 + errors = [] - for filepath in filepaths: - p = Path(filepath) - if not p.exists(): - results.append({"path": filepath, "success": False, "error": "File not found"}) - continue - + # Refresh metadata for destruct mode + video_map = fetch_all_metadata() if destruct else {} + + for path in paths: try: - vid_id = extract_id_from_filename(p.name) - - # DESTRUCT MODE: Delete source too - if destruct_mode and vid_id: - meta = video_map.get(vid_id) - if meta and meta.get('filesystem_path'): - source_path = Path(meta['filesystem_path']) - if source_path.exists(): - source_path.unlink() - log(f" [DESTRUCT] Deleted source: {source_path}") - - # Also check lost_media table - with get_db() as conn: - conn.execute("DELETE FROM lost_media WHERE video_id = ?", (vid_id,)) - conn.commit() + # 1. Destruct Source if enabled + if destruct: + source_deleted = False + for vid_id, meta in video_map.items(): + if meta.get('path') == path or meta.get('filesystem_path') == path: + source_path = meta.get('filesystem_path') + if source_path and os.path.exists(source_path): + os.remove(source_path) + log(f"☢️ [DESTRUCT] Deleted source: {source_path}") + source_deleted = True + break + if not source_deleted: + log(f"⚠️ [DESTRUCT] Source not found for: {path}") - # DELETE TARGET (Symlink/Resource) - p.unlink() - - # Clean up empty parent folder if it's a video folder - parent = p.parent - if parent not in [TARGET_DIR, HIDDEN_DIR, SOURCE_DIR] and parent.name != "source": - try: - if not any(parent.iterdir()): + # 2. Delete Target + p = Path(path) + if p.exists(): + if p.is_dir(): + shutil.rmtree(p) + else: + p.unlink() + + # 3. Cleanup empty parent + parent = p.parent + if parent != Path(TARGET_DIR) and parent != Path(HIDDEN_DIR): + if parent.exists() and not any(parent.iterdir()): parent.rmdir() - log(f" [CLEANUP] Removed empty dir: {parent}") - except: - pass - - results.append({"path": filepath, "success": True}) + log(f"🧹 [CLEANUP] Removed empty folder: {parent}") + + success_count += 1 except Exception as e: - results.append({"path": filepath, "success": False, "error": str(e)}) - log(f"❌ Failed to delete {filepath}: {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({ - "results": results, - "success_count": len([r for r in results if r["success"]]), - "fail_count": len([r for r in results if not r["success"]]) + "success_count": success_count, + "fail_count": fail_count, + "errors": errors[:5] }) @app.route("/api/recovery/delete", methods=["POST"]) diff --git a/ui/src/components/RecoveryModal.svelte b/ui/src/components/RecoveryModal.svelte index 684fbd0..63ad641 100644 --- a/ui/src/components/RecoveryModal.svelte +++ b/ui/src/components/RecoveryModal.svelte @@ -8,7 +8,7 @@ let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] }; let pollInterval: ReturnType; - // New State for Destruct Mode & Multi-selection + // State for Destruct Mode & Multi-selection let destructMode = false; let selectedPaths = new Set(); @@ -19,6 +19,22 @@ 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; try { @@ -111,7 +127,6 @@ } if (!confirm(msg)) return; - if (destructMode && !confirm("FINAL WARNING: This is IRREVERSIBLE. Delete source file now?")) return; try { @@ -128,7 +143,10 @@ alert("Deleted."); if (activeTab === "channels") fetchChannelVideos(selectedChannel); else startScan(); - } else alert("Error deleting file."); + } else { + const err = d.errors?.[0] || "Unknown error"; + alert(`Error deleting file: ${err}`); + } } catch (e) { alert(e); } @@ -155,7 +173,14 @@ headers: { "Content-Type": "application/json" }, }); const d = await res.json(); - alert(`Batch complete. Success: ${d.success_count}, Failed: ${d.fail_count}`); + + 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(); @@ -170,7 +195,7 @@ } else { selectedPaths.add(path); } - selectedPaths = selectedPaths; // Trigger Svelte reactivity + selectedPaths = selectedPaths; } function toggleAll() { @@ -184,8 +209,12 @@ $: 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(); }); @@ -193,11 +222,11 @@ class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4" >

Advanced Recovery @@ -207,16 +236,36 @@

+ + {#if sourceRO || targetRO} +
+ +
+ Filesystem Alert: + {#if sourceRO && targetRO} + Both Source AND Target directories are currently READ-ONLY. Deletion and reorganization will fail. + {:else if sourceRO} + Source archive is READ-ONLY. Destruct Mode will fail. + {:else} + Target library is READ-ONLY. Symlink cleanup will fail. + {/if} +
+ +
+ {/if} +
@@ -238,29 +288,31 @@ -
Status: {status}
+
Status: {status}
-
+
{#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab}
-
+
{#if searchingVideos} -
- Fetching videos... +
+
+ QUERYING TA DATABASE...
{:else if channelVideos.length > 0} - - +
+ @@ -344,9 +399,9 @@
{item.title}
{:else} -
- - Select a channel to view its videos. +
+ + No channel selected
{/if}
@@ -356,11 +411,11 @@ class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4" >
-
SYSTEM WIDE SCAN IN PROGRESS...
+
SYSTEM WIDE SCAN IN PROGRESS...
{:else} - - +
+ - - - + + + @@ -408,33 +463,35 @@
{#if activeTab === "unindexed"} RECOVER {:else if activeTab === "redundant"} DELETE {:else if activeTab === "lost"} FORCE DELETE {:else} RESCUE {/if}
@@ -445,10 +502,10 @@ @@ -464,4 +521,23 @@ .accent-neon-cyan { accent-color: #00f3ff; } + + .bg-cyber-card { + background: linear-gradient(135deg, #0f0f13 0%, #050505 100%); + } + + /* Custom Scrollbar for better Cyberpunk feel */ + ::-webkit-scrollbar { + width: 4px; + } + ::-webkit-scrollbar-track { + background: rgba(15, 15, 15, 0.5); + } + ::-webkit-scrollbar-thumb { + background: #00f3ff22; + border-radius: 10px; + } + ::-webkit-scrollbar-thumb:hover { + background: #00f3ff66; + } From 003f6a4dd9138f2d271bc2abbb0a3dcb1b4d265d Mon Sep 17 00:00:00 2001 From: wander Date: Sun, 8 Mar 2026 05:12:19 -0400 Subject: [PATCH 07/19] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ``` From 45a1f0ae9314f21abad384c7496dd11dff2551ba Mon Sep 17 00:00:00 2001 From: wander Date: Sun, 8 Mar 2026 05:25:11 -0400 Subject: [PATCH 08/19] feat: enhance permission logging and display detailed errors in UI --- ta_symlink.py | 69 +++++++++++++++++++++++--- ui/src/components/RecoveryModal.svelte | 5 +- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/ta_symlink.py b/ta_symlink.py index 633d70e..24abd4d 100644 --- a/ta_symlink.py +++ b/ta_symlink.py @@ -23,6 +23,7 @@ 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") HEADERS = {"Authorization": f"Token {API_TOKEN}"} # Serve static files from ui/dist @@ -1147,6 +1148,8 @@ def api_recovery_delete_batch(): fail_count = 0 errors = [] + log(f"🔥 Batch Delete started. Items: {len(paths)}, Destruct: {destruct}") + # Refresh metadata for destruct mode video_map = fetch_all_metadata() if destruct else {} @@ -1159,10 +1162,15 @@ def api_recovery_delete_batch(): if meta.get('path') == path or meta.get('filesystem_path') == path: source_path = meta.get('filesystem_path') if source_path and os.path.exists(source_path): - os.remove(source_path) - log(f"☢️ [DESTRUCT] Deleted source: {source_path}") - source_deleted = True - break + try: + os.remove(source_path) + log(f"☢️ [DESTRUCT] Deleted source: {source_path}") + source_deleted = True + break + except Exception as se: + log(f"❌ [DESTRUCT] Failed to delete source {source_path}: {se}") + raise Exception(f"Source deletion failed: {se}") + if not source_deleted: log(f"⚠️ [DESTRUCT] Source not found for: {path}") @@ -1173,13 +1181,16 @@ def api_recovery_delete_batch(): shutil.rmtree(p) else: p.unlink() + log(f"🗑️ Deleted target: {path}") # 3. Cleanup empty parent parent = p.parent if parent != Path(TARGET_DIR) and parent != Path(HIDDEN_DIR): if parent.exists() and not any(parent.iterdir()): - parent.rmdir() - log(f"🧹 [CLEANUP] Removed empty folder: {parent}") + try: + parent.rmdir() + log(f"🧹 [CLEANUP] Removed empty folder: {parent}") + except: pass success_count += 1 except Exception as e: @@ -1195,6 +1206,52 @@ def api_recovery_delete_batch(): "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(): diff --git a/ui/src/components/RecoveryModal.svelte b/ui/src/components/RecoveryModal.svelte index 63ad641..891224b 100644 --- a/ui/src/components/RecoveryModal.svelte +++ b/ui/src/components/RecoveryModal.svelte @@ -248,11 +248,14 @@
Filesystem Alert: {#if sourceRO && targetRO} - Both Source AND Target directories are currently READ-ONLY. Deletion and reorganization will fail. + 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}
Video IDFilename / PathSize/InfoIDFilename / Target PathInfo Action
- - Nothing detected in this category. + + NO ANOMALIES DETECTED IN THIS SECTOR.