Compare commits

..

18 commits

Author SHA1 Message Date
996e074296 fix: use vid_type from API to correctly identify streams
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 07:09:52 -04:00
b7ca75b57e fix: resolve AttributeError when accessing sqlite3.Row in api_get_channel_videos
All checks were successful
Docker Build / build (push) Successful in 12s
2026-03-08 06:48:54 -04:00
568601181d fix: resolve AttributeError and improve lint compliance in api_get_channel_videos
All checks were successful
Docker Build / build (push) Successful in 12s
2026-03-08 06:46:14 -04:00
581de707d7 fix: robust ID matching and DB migrations for vanished symlinks
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 06:42:15 -04:00
04c7c5ec5e feat: implement stream detection and organization into #streams subfolders
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 06:24:19 -04:00
6e0d081799 fix: synchronize database records with filesystem deletions
All checks were successful
Docker Build / build (push) Successful in 12s
2026-03-08 06:19:08 -04:00
a296c932f7 fix: restrict recursive destruction search to video files and exclude tcconf
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 06:10:15 -04:00
b57aecf7cb fix: make host paths configurable and add recursive search fallback for destruct mode
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 06:03:04 -04:00
e99d21fac7 fix: implement symlink resolution for more reliable source deletion in destruct mode
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 05:56:10 -04:00
8876469c43 fix: implement TA path translation and use lexists for reliable deletion
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 05:47:55 -04:00
88bc8229c9 fix: resolve path mismatch in destruct mode and prevent empty folder creation
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 05:41:54 -04:00
45a1f0ae93 feat: enhance permission logging and display detailed errors in UI
All checks were successful
Docker Build / build (push) Successful in 14s
2026-03-08 05:25:11 -04:00
85f7a18883 feat: add filesystem permissions check and improved batch deletion error handling
All checks were successful
Docker Build / build (push) Successful in 15s
2026-03-08 05:10:37 -04:00
62428c313b feat: add channels tab to advanced recovery for mass deletion
All checks were successful
Docker Build / build (push) Successful in 14s
2026-03-08 04:51:42 -04:00
29c3339c39 feat: implement advanced recovery destruct mode and multi-selection
All checks were successful
Docker Build / build (push) Successful in 14s
2026-03-08 04:41:49 -04:00
dd25df4bdc fix: prevent orphaned folder creation and improve cleanup
All checks were successful
Docker Build / build (push) Successful in 13s
2026-03-08 04:29:45 -04:00
8a9f8fbb35 Update .gitea/workflows/docker-build.yml
All checks were successful
Docker Build / build (push) Successful in 21s
2026-03-08 04:02:03 -04:00
394c27401d feat: implement session-based authentication and modern login UI 2026-03-08 04:00:14 -04:00
7 changed files with 963 additions and 215 deletions

View file

@ -2,7 +2,7 @@ name: Docker Build
on: on:
push: push:
branches: [ "main" ] branches: [ "main" , "feature/ui-login-rework"]
paths-ignore: paths-ignore:
- 'README.md' - 'README.md'
- '.gitignore' - '.gitignore'

View file

@ -47,7 +47,7 @@ VIDEO_URL=http://localhost:8457/video
1. Clone this repo and navigate into it: 1. Clone this repo and navigate into it:
```bash ```bash
git clone https://github.com/wander/ta-organizerr.git git clone https://github.com/Salpertio/tubesorter.git
cd tubesorter cd tubesorter
``` ```

View file

@ -9,7 +9,7 @@ import ipaddress
import shutil import shutil
from datetime import datetime from datetime import datetime
from functools import wraps 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 # Load config from environment variables
API_URL = os.getenv("API_URL", "http://localhost:8457/api") API_URL = os.getenv("API_URL", "http://localhost:8457/api")
@ -23,11 +23,14 @@ SOURCE_DIR = Path("/app/source")
TARGET_DIR = Path("/app/target") TARGET_DIR = Path("/app/target")
HIDDEN_DIR = Path("/app/hidden") HIDDEN_DIR = Path("/app/hidden")
IMPORT_DIR = Path("/app/import") 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}"} HEADERS = {"Authorization": f"Token {API_TOKEN}"}
# Serve static files from ui/dist # Serve static files from ui/dist
STATIC_FOLDER = os.path.join(os.getcwd(), 'ui', 'dist') STATIC_FOLDER = os.path.join(os.getcwd(), 'ui', 'dist')
app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path='/') app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path='/')
app.secret_key = os.getenv("FLASK_SECRET_KEY", "tubesortermagicpika") # Change in production!
# Database setup # Database setup
import sqlite3 import sqlite3
@ -55,6 +58,7 @@ def init_db():
published TEXT, published TEXT,
symlink TEXT, symlink TEXT,
status TEXT, status TEXT,
is_live BOOLEAN DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS lost_media ( CREATE TABLE IF NOT EXISTS lost_media (
@ -66,6 +70,17 @@ def init_db():
channel_name TEXT PRIMARY KEY 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() conn.commit()
# Retry loop for DB initialization to prevent crash on SMB lock # Retry loop for DB initialization to prevent crash on SMB lock
@ -95,6 +110,48 @@ def log(msg):
if len(log_buffer) > 1000: if len(log_buffer) > 1000:
log_buffer.pop(0) 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): def tlog(msg):
"""Logs a message to the transcode log buffer.""" """Logs a message to the transcode log buffer."""
print(f"[TRANSCODE] {msg}", flush=True) print(f"[TRANSCODE] {msg}", flush=True)
@ -103,6 +160,12 @@ def tlog(msg):
if len(transcode_log_buffer) > 500: if len(transcode_log_buffer) > 500:
transcode_log_buffer.pop(0) 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(): def detect_encoder():
"""Detect best available hardware encoder.""" """Detect best available hardware encoder."""
import subprocess import subprocess
@ -319,7 +382,9 @@ def fetch_all_metadata():
video_map[vid_id] = { video_map[vid_id] = {
"title": title, "title": title,
"channel_name": channel_name, "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 # Check pagination to see if we are done
@ -343,53 +408,55 @@ def fetch_all_metadata():
def cleanup_old_folders(): def cleanup_old_folders():
""" """
Scans TARGET_DIR for folders containing '+00:00'. Scans both TARGET_DIR and HIDDEN_DIR for empty or orphaned folders.
Safely deletes them ONLY if they contain no real files (only symlinks or empty). 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 cleaned_count = 0
skipped_count = 0
if not TARGET_DIR.exists(): for root in [TARGET_DIR, HIDDEN_DIR]:
return if not root.exists():
continue
# Walk top-down # Walk top-down: Channels
for channel_dir in TARGET_DIR.iterdir(): for channel_dir in root.iterdir():
if not channel_dir.is_dir(): if not channel_dir.is_dir():
continue continue
for video_dir in channel_dir.iterdir(): # Videos
for video_dir in list(channel_dir.iterdir()): # List to allow removal
if not video_dir.is_dir(): if not video_dir.is_dir():
continue continue
if "+00:00" in video_dir.name: # Check if it contains any real files
# Check safety
safe_to_delete = True safe_to_delete = True
reason = ""
for item in video_dir.iterdir(): for item in video_dir.iterdir():
if not item.is_symlink(): if not item.is_symlink():
# Found a real file! Unsafe!
safe_to_delete = False safe_to_delete = False
reason = "Contains real files"
break break
if safe_to_delete: if safe_to_delete:
try: try:
# Remove all symlinks first # Remove all symlinks first
for item in video_dir.iterdir(): for item in list(video_dir.iterdir()):
item.unlink() item.unlink()
# Remove directory # Remove video directory
video_dir.rmdir() video_dir.rmdir()
log(f" [DELETED] {video_dir.name}") log(f" [DELETED VIDEO] {video_dir.name}")
cleaned_count += 1 cleaned_count += 1
except Exception as e: except Exception as e:
log(f" ❌ Failed to delete {video_dir.name}: {e}") pass # Likely not empty
else:
log(f" ⚠️ SKIPPING {video_dir.name} - {reason}")
skipped_count += 1
log(f"🧹 Cleanup complete. Removed: {cleaned_count}, Skipped: {skipped_count}") # 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} empty/orphaned directories.")
def check_orphaned_links(): def check_orphaned_links():
""" """
@ -504,9 +571,12 @@ def scan_for_unindexed_videos():
"lost": [] "lost": []
} }
# Helper to check if file is video results = {
def is_video(f): "unindexed": [],
return f.suffix.lower() in ['.mp4', '.mkv', '.webm', '.mov'] "redundant": [],
"rescue": [],
"lost": []
}
# --- Scan SOURCE_DIR (Standard Orphan Check) --- # --- Scan SOURCE_DIR (Standard Orphan Check) ---
if SOURCE_DIR.exists(): if SOURCE_DIR.exists():
@ -767,74 +837,63 @@ def process_videos():
if not channel_path.is_dir(): if not channel_path.is_dir():
continue continue
for video_file in channel_path.glob("*.*"): 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 # Lookup in local map
meta = video_map.get(video_id) meta = video_map.get(video_id)
if not meta: if not meta:
continue continue
sanitized_channel_name = sanitize(meta["channel_name"])
# Determine target root sanitized_channel_name = sanitize(meta["channel_name"])
is_hidden = meta["channel_name"] in hidden_channels is_hidden = meta["channel_name"] in hidden_channels
target_root = HIDDEN_DIR if is_hidden else TARGET_DIR target_root = HIDDEN_DIR if is_hidden else TARGET_DIR
other_root = TARGET_DIR if is_hidden else HIDDEN_DIR other_root = TARGET_DIR if is_hidden else HIDDEN_DIR
# Check if channel exists in the WRONG place and MOVE it (Migration/Toggle) # Migration Logic
wrong_channel_dir = other_root / sanitized_channel_name wrong_channel_dir = other_root / sanitized_channel_name
correct_channel_dir = target_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(): if wrong_channel_dir.exists():
try: 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(): if not correct_channel_dir.exists():
shutil.move(str(wrong_channel_dir), str(correct_channel_dir)) shutil.move(str(wrong_channel_dir), str(correct_channel_dir))
log(f" [MOVE] Moved {sanitized_channel_name} to {target_root.name} (Status Change)") log(f" [MOVE] Migrated {sanitized_channel_name} to {target_root.name}")
else: else:
# Destination exists. We must merge. for item in list(wrong_channel_dir.iterdir()):
# Move items one by one.
for item in wrong_channel_dir.iterdir():
dest_item = correct_channel_dir / item.name dest_item = correct_channel_dir / item.name
if not dest_item.exists(): if not dest_item.exists():
shutil.move(str(item), str(dest_item)) 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: try:
wrong_channel_dir.rmdir() wrong_channel_dir.rmdir()
except OSError: except OSError:
log(f" ⚠️ Could not remove old dir {wrong_channel_dir} (not empty?)") pass
except Exception as e: except Exception as e:
log(f"Failed to move {sanitized_channel_name} from old location: {e}") log(f" ❌ Migration error for {sanitized_channel_name}: {e}")
# Folder Creation (Delay until link check)
channel_dir = target_root / sanitized_channel_name channel_dir = target_root / sanitized_channel_name
channel_dir.mkdir(parents=True, exist_ok=True)
# Stream Organization
if meta.get("is_live"):
channel_dir = channel_dir / "#streams"
sanitized_title = sanitize(meta["title"]) sanitized_title = sanitize(meta["title"])
folder_name = f"{meta['published']} - {sanitized_title}" folder_name = f"{meta['published']} - {sanitized_title}"
video_dir = channel_dir / folder_name video_dir = channel_dir / folder_name
video_dir.mkdir(parents=True, exist_ok=True)
actual_file = next(channel_path.glob(f"{video_id}.*"), None) host_path_root = HOST_SOURCE_BASE
if not actual_file: host_source_path = host_path_root / video_file.relative_to(SOURCE_DIR)
continue dest_file = video_dir / f"video{video_file.suffix}"
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: try:
link_success = False
if dest_file.exists(): if dest_file.exists():
if dest_file.is_symlink(): if dest_file.is_symlink():
current_target = Path(os.readlink(dest_file)) current_target = Path(os.readlink(dest_file))
@ -843,25 +902,28 @@ def process_videos():
os.symlink(host_source_path, dest_file) os.symlink(host_source_path, dest_file)
log(f" [FIX] Relinked: {folder_name}") log(f" [FIX] Relinked: {folder_name}")
new_links += 1 new_links += 1
link_success = True
else: else:
verified_links += 1 verified_links += 1
link_success = True
else: else:
# It's a file or something else, replace it? No, unsafe. # Create directories ONLY NOW
pass channel_dir.mkdir(parents=True, exist_ok=True)
else: video_dir.mkdir(parents=True, exist_ok=True)
os.symlink(host_source_path, dest_file) os.symlink(host_source_path, dest_file)
log(f" [NEW] Linked: {folder_name}") log(f" [NEW] Linked: {folder_name}")
new_links += 1 new_links += 1
except Exception: link_success = True
pass except Exception as e:
log(f" ❌ Link error for {folder_name}: {e}")
# Store in database # Store in database
conn.execute(""" conn.execute("""
INSERT OR REPLACE INTO videos INSERT OR REPLACE INTO videos
(video_id, title, channel, published, symlink, status) (video_id, title, channel, published, symlink, is_live, status)
VALUES (?, ?, ?, ?, ?, 'linked') VALUES (?, ?, ?, ?, ?, ?, 'linked')
""", (video_id, meta["title"], meta["channel_name"], """, (video_id, meta["title"], meta["channel_name"],
meta["published"], str(dest_file))) meta["published"], str(dest_file), 1 if meta.get("is_live") else 0))
processed_videos.append({ processed_videos.append({
"video_id": video_id, "video_id": video_id,
@ -918,22 +980,44 @@ def check_auth(username, password):
"""Checks whether a username/password combination is valid.""" """Checks whether a username/password combination is valid."""
return username == UI_USERNAME and password == UI_PASSWORD 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): def requires_auth(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
auth = request.authorization if not session.get('logged_in'):
if not auth or not check_auth(auth.username, auth.password): if request.path.startswith('/api/'):
return authenticate() return jsonify({"error": "Unauthorized"}), 401
return redirect('/login')
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated 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("/") @app.route("/")
@requires_auth @requires_auth
def index(): def index():
@ -1131,6 +1215,186 @@ def api_recovery_start():
"status": "completed" if success else "failed" "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"]) @app.route("/api/recovery/delete", methods=["POST"])
@requires_auth @requires_auth
def api_recovery_delete(): def api_recovery_delete():
@ -1173,6 +1437,8 @@ def api_recovery_delete():
if vid_id: if vid_id:
with get_db() as conn: with get_db() as conn:
conn.execute("DELETE FROM lost_media WHERE video_id = ?", (vid_id,)) 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() conn.commit()
log(f"🗑️ Deleted file: {filepath}") log(f"🗑️ Deleted file: {filepath}")
@ -1181,6 +1447,43 @@ def api_recovery_delete():
log(f"❌ Delete failed: {e}") log(f"❌ Delete failed: {e}")
return jsonify({"error": str(e)}), 500 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']) @app.route('/api/recovery/force', methods=['POST'])
@requires_auth @requires_auth
def api_recovery_force(): def api_recovery_force():

View file

@ -24,6 +24,10 @@
async function fetchData() { async function fetchData() {
try { try {
const res = await fetch("/api/status"); 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"); if (!res.ok) throw new Error("Failed to fetch status");
const data = await res.json(); const data = await res.json();

View file

@ -2,12 +2,39 @@
import { createEventDispatcher, onMount, onDestroy } from "svelte"; import { createEventDispatcher, onMount, onDestroy } from "svelte";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let activeTab: "unindexed" | "rescue" | "redundant" | "lost" = "unindexed"; let activeTab: "unindexed" | "rescue" | "redundant" | "lost" | "channels" = "unindexed";
let scanning = false; let scanning = false;
let status = "idle"; let status = "idle";
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] }; let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
let pollInterval: ReturnType<typeof setInterval>; let pollInterval: ReturnType<typeof setInterval>;
// State for Destruct Mode & Multi-selection
let destructMode = false;
let selectedPaths = new Set<string>();
// 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() { async function startScan() {
scanning = true; scanning = true;
try { try {
@ -43,12 +70,38 @@
}, 2000); }, 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(() => { onDestroy(() => {
if (pollInterval) clearInterval(pollInterval); if (pollInterval) clearInterval(pollInterval);
}); });
async function recoverFile(path: string, isBatch = false) { async function recoverFile(path: string, isBatch = false) {
// Implementation mirrors existing JS logic
if (!isBatch && !confirm("Recover this file?")) return; if (!isBatch && !confirm("Recover this file?")) return;
try { try {
const res = await fetch("/api/recovery/start", { const res = await fetch("/api/recovery/start", {
@ -59,112 +112,350 @@
const d = await res.json(); const d = await res.json();
if (!isBatch) { if (!isBatch) {
alert(d.message); alert(d.message);
startScan(); if (activeTab === "channels") fetchChannelVideos(selectedChannel);
} // Refresh else startScan();
}
} catch (e) { } catch (e) {
alert(e); alert(e);
} }
} }
async function deleteFile(path: string) { 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 { try {
const res = await fetch("/api/recovery/delete", { const res = await fetch("/api/recovery/delete-batch", {
method: "POST", method: "POST",
body: JSON.stringify({ filepath: path }), body: JSON.stringify({
filepaths: [path],
destruct_mode: destructMode
}),
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
const d = await res.json(); const d = await res.json();
if (d.success) { if (d.success_count > 0) {
alert("Deleted."); alert("Deleted.");
startScan(); if (activeTab === "channels") fetchChannelVideos(selectedChannel);
} else alert("Error: " + d.error); else startScan();
} else {
const err = d.errors?.[0] || "Unknown error";
alert(`Error deleting file: ${err}`);
}
} catch (e) { } catch (e) {
alert(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();
});
</script> </script>
<div <div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
> >
<div <div
class="bg-cyber-card border border-neon-cyan/30 rounded-xl w-full max-w-5xl h-[80vh] flex flex-col shadow-[0_0_50px_rgba(0,243,255,0.1)]" class="bg-cyber-card border border-neon-cyan/30 rounded-xl w-full max-w-5xl h-[80vh] flex flex-col shadow-[0_0_50px_rgba(0,243,255,0.1)] overflow-hidden"
> >
<!-- Header --> <!-- Header -->
<div <div
class="p-5 border-b border-gray-800 flex justify-between items-center" class="p-5 border-b border-gray-800 flex justify-between items-center bg-gray-900/40"
> >
<h2 class="text-xl font-bold text-white flex items-center gap-2"> <h2 class="text-xl font-bold text-white flex items-center gap-2">
<i class="bi bi-bandaid text-neon-pink"></i> Advanced Recovery <i class="bi bi-bandaid text-neon-pink"></i> Advanced Recovery
{#if destructMode}
<span class="text-xs bg-red-600 text-white px-2 py-0.5 rounded animate-pulse ml-2 font-mono">DESTRUCT MODE ACTIVE</span>
{/if}
</h2> </h2>
<button <button
on:click={() => dispatch("close")} on:click={() => dispatch("close")}
class="text-gray-500 hover:text-white" class="text-gray-500 hover:text-white transition-colors"
aria-label="Close"><i class="bi bi-x-lg"></i></button aria-label="Close"><i class="bi bi-x-lg"></i></button
> >
</div> </div>
<!-- Permission Warnings -->
{#if sourceRO || targetRO}
<div class="bg-red-900/30 border-b border-red-500/50 p-3 flex items-center gap-3 animate-in fade-in slide-in-from-top duration-300">
<i class="bi bi-exclamation-octagon-fill text-red-500 text-lg"></i>
<div class="text-xs text-red-200">
<span class="font-bold uppercase">Filesystem Alert:</span>
{#if sourceRO && targetRO}
Both Source AND Target directories are currently <span class="text-white underline">READ-ONLY</span>.
<div class="mt-1 opacity-70 italic">Errors: {permissions?.source?.message} | {permissions?.target?.message}</div>
{:else if sourceRO}
Source archive is <span class="text-white underline">READ-ONLY</span>. Destruct Mode will fail.
<div class="mt-1 opacity-70 italic">Error: {permissions?.source?.message}</div>
{:else}
Target library is <span class="text-white underline">READ-ONLY</span>. Symlink cleanup will fail.
<div class="mt-1 opacity-70 italic">Error: {permissions?.target?.message}</div>
{/if}
</div>
<button on:click={checkPermissions} class="ml-auto text-[10px] bg-red-500/20 hover:bg-red-500/40 px-2 py-1 rounded border border-red-500/30 transition-colors">
Retry Check
</button>
</div>
{/if}
<!-- Controls --> <!-- Controls -->
<div class="p-4 bg-gray-900/50 flex justify-between items-center"> <div class="p-4 bg-gray-900/50 flex flex-wrap gap-4 justify-between items-center">
<div class="flex items-center gap-3">
<button <button
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-colors" class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-all transform active:scale-95 disabled:opacity-50"
on:click={startScan} on:click={startScan}
disabled={scanning} disabled={scanning}
> >
{scanning ? "Scanning..." : "Run System Scan"} {scanning ? "Scanning..." : "Run System Scan"}
</button> </button>
<div class="text-xs text-mono text-gray-500">Status: {status}</div>
{#if selectedPaths.size > 0}
<button
class="px-6 py-2 rounded font-bold text-white bg-red-600 hover:bg-red-500 transition-all transform active:scale-95 flex items-center gap-2 shadow-[0_0_15px_rgba(220,38,38,0.4)]"
on:click={deleteSelected}
disabled={targetRO || (destructMode && sourceRO)}
>
<i class="bi bi-trash"></i> Delete Selected ({selectedPaths.size})
</button>
{/if}
</div>
<div class="flex items-center gap-6">
<!-- Destruct Mode Toggle -->
<label class="relative inline-flex items-center cursor-pointer group">
<input type="checkbox" bind:checked={destructMode} class="sr-only peer">
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-red-600"></div>
<span class="ml-3 text-sm font-medium text-gray-300 group-hover:text-white transition-colors flex items-center gap-2">
Destruct Mode
{#if destructMode}
<i class="bi bi-exclamation-triangle-fill text-red-500 animate-pulse"></i>
{/if}
</span>
</label>
<div class="text-xs text-mono text-gray-500">Status: <span class="text-neon-cyan underline decoration-dotted">{status}</span></div>
</div>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="flex border-b border-gray-800 px-4"> <div class="flex border-b border-gray-800 px-4 bg-gray-900/20">
{#each ["unindexed", "rescue", "redundant", "lost"] as tab} {#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab}
<button <button
class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-colors flex items-center gap-2 class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-all flex items-center gap-2
{activeTab === tab {activeTab === tab
? 'border-neon-cyan text-neon-cyan' ? 'border-neon-cyan text-neon-cyan bg-neon-cyan/5'
: 'border-transparent text-gray-500 hover:text-gray-300'}" : 'border-transparent text-gray-500 hover:text-gray-300 hover:bg-white/5'}"
on:click={() => (activeTab = tab as any)} on:click={() => { activeTab = tab as any; selectedPaths = new Set(); }}
> >
{tab} {tab}
<span class="bg-gray-800 text-xs px-1.5 rounded-full" {#if tab !== 'channels'}
<span class="bg-gray-800 text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.2rem] text-center"
>{results[tab]?.length || 0}</span >{results[tab]?.length || 0}</span
> >
{/if}
</button> </button>
{/each} {/each}
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex-grow overflow-y-auto p-4 bg-black/30"> <div class="flex-grow overflow-y-auto p-4 bg-black/30">
{#if scanning && (!results[activeTab] || results[activeTab].length === 0)} {#if activeTab === "channels"}
<div <div class="flex flex-col h-full gap-4">
class="flex items-center justify-center h-full text-neon-cyan animate-pulse" <!-- Channel Selector -->
<div class="flex gap-4 items-end">
<div class="flex-grow">
<label class="block text-[10px] text-gray-500 mb-1 uppercase tracking-widest">Select Channel (Indexed Only)</label>
<select
class="w-full bg-gray-900 border border-gray-700 rounded p-2 text-white outline-none focus:border-neon-cyan transition-colors"
value={selectedChannel}
on:change={(e) => fetchChannelVideos(e.currentTarget.value)}
> >
Scanning... <option value="">-- Choose a Channel --</option>
{#each channels as channel}
<option value={channel}>{channel}</option>
{/each}
</select>
</div>
<button
class="p-2.5 rounded bg-gray-800 text-white hover:bg-gray-700 transition-colors border border-gray-700"
on:click={fetchChannels}
title="Refresh Channel List"
>
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<!-- Video Table -->
<div class="flex-grow overflow-y-auto border border-gray-800 rounded bg-gray-900/20">
{#if searchingVideos}
<div class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4">
<div class="w-8 h-8 border-2 border-neon-cyan border-t-transparent rounded-full animate-spin"></div>
<span class="text-xs font-mono animate-pulse">QUERYING TA DATABASE...</span>
</div>
{:else if channelVideos.length > 0}
<table class="w-full text-left text-[11px] text-gray-300 font-mono border-collapse">
<thead class="sticky top-0 bg-gray-900 z-10">
<tr class="text-gray-500 border-b border-gray-800">
<th class="p-3 w-10">
<input
type="checkbox"
checked={allSelected}
on:change={toggleAll}
class="accent-neon-cyan"
>
</th>
<th class="p-3">Video ID</th>
<th class="p-3">Title</th>
<th class="p-3 text-right">Action</th>
</tr>
</thead>
<tbody>
{#each channelVideos as item}
<tr class="border-b border-gray-800/50 hover:bg-white/5 transition-colors {selectedPaths.has(item.path) ? 'bg-neon-cyan/5' : ''}">
<td class="p-3 text-center">
<input
type="checkbox"
checked={selectedPaths.has(item.path)}
on:change={() => toggleSelect(item.path)}
class="accent-neon-cyan"
>
</td>
<td class="p-3 text-neon-pink font-bold">{item.video_id}</td>
<td class="p-3 truncate max-w-[400px]" title={item.title}>{item.title}</td>
<td class="p-3 text-right">
<button
class="text-red-500 hover:text-red-400 font-bold transition-colors disabled:opacity-30"
on:click={() => deleteFile(item.path)}
disabled={targetRO || (destructMode && sourceRO)}
>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="flex flex-col items-center justify-center h-full text-gray-700 gap-3">
<i class="bi bi-broadcast text-4xl opacity-10"></i>
<span class="text-xs uppercase tracking-widest font-bold">No channel selected</span>
</div>
{/if}
</div>
</div>
{:else if scanning && (!results[activeTab] || results[activeTab].length === 0)}
<div
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
>
<div class="w-12 h-12 border-4 border-neon-cyan border-t-transparent rounded-full animate-spin"></div>
<div class="animate-pulse font-mono tracking-widest">SYSTEM WIDE SCAN IN PROGRESS...</div>
</div> </div>
{:else} {:else}
<table class="w-full text-left text-xs text-gray-300 font-mono"> <table class="w-full text-left text-[11px] text-gray-300 font-mono border-collapse">
<thead> <thead class="sticky top-0 bg-gray-900 z-10">
<tr class="text-gray-500 border-b border-gray-800"> <tr class="text-gray-500 border-b border-gray-800">
<th class="p-3">Video ID</th> <th class="p-3 w-10">
<th class="p-3">Filename / Path</th> <input
<th class="p-3">Size/Info</th> type="checkbox"
checked={allSelected}
on:change={toggleAll}
class="accent-neon-cyan"
>
</th>
<th class="p-3">ID</th>
<th class="p-3">Filename / Target Path</th>
<th class="p-3">Info</th>
<th class="p-3 text-right">Action</th> <th class="p-3 text-right">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each results[activeTab] || [] as item} {#each results[activeTab] || [] as item}
<tr <tr
class="border-b border-gray-800/50 hover:bg-white/5" class="border-b border-gray-800/50 hover:bg-white/5 transition-colors {selectedPaths.has(item.path) ? 'bg-neon-cyan/5' : ''}"
> >
<td class="p-3 text-neon-pink" <td class="p-3 text-center">
<input
type="checkbox"
checked={selectedPaths.has(item.path)}
on:change={() => toggleSelect(item.path)}
class="accent-neon-cyan"
>
</td>
<td class="p-3 text-neon-pink font-bold"
>{item.video_id}</td >{item.video_id}</td
> >
<td <td
class="p-3 truncate max-w-[300px]" class="p-3 truncate max-w-[350px] group relative"
title={item.path} title={item.path}
>{item.filename || item.path}</td >
<span class="group-hover:text-white transition-colors">{item.filename || item.path}</span>
</td
> >
<td class="p-3" <td class="p-3"
>{item.size_mb >{item.size_mb
@ -172,46 +463,53 @@
: item.ta_source || "-"}</td : item.ta_source || "-"}</td
> >
<td class="p-3 text-right"> <td class="p-3 text-right">
<div class="flex justify-end gap-3">
{#if activeTab === "unindexed"} {#if activeTab === "unindexed"}
<button <button
class="text-neon-green hover:underline" class="text-neon-green hover:underline flex items-center gap-1 transition-all hover:scale-105"
on:click={() => on:click={() =>
recoverFile(item.path)} recoverFile(item.path)}
>Recover</button ><i class="bi bi-plus-circle"></i> RECOVER</button
> >
{:else if activeTab === "redundant"} {:else if activeTab === "redundant"}
<button <button
class="text-red-500 hover:underline" class="text-red-500 hover:underline font-bold transition-all hover:scale-105 disabled:opacity-30"
on:click={() => on:click={() =>
deleteFile(item.path)} deleteFile(item.path)}
>Delete</button disabled={targetRO || (destructMode && sourceRO)}
><i class="bi bi-trash"></i> DELETE</button
> >
{:else if activeTab === "lost"} {:else if activeTab === "lost"}
<button <button
class="text-neon-yellow hover:underline mr-2" class="text-neon-yellow hover:underline flex items-center gap-1"
>Force</button ><i class="bi bi-lightning-fill"></i> FORCE</button
> >
<button <button
class="text-red-500 hover:underline" class="text-red-500 hover:underline font-bold disabled:opacity-30"
on:click={() => on:click={() =>
deleteFile(item.path)} deleteFile(item.path)}
>Delete</button disabled={targetRO || (destructMode && sourceRO)}
><i class="bi bi-trash"></i> DELETE</button
> >
{:else} {:else}
<button <button
class="text-neon-pink hover:underline" class="text-neon-pink hover:underline flex items-center gap-1"
>Rescue</button ><i class="bi bi-rescue-ambulance"></i> RESCUE</button
> >
{/if} {/if}
</div>
</td> </td>
</tr> </tr>
{/each} {/each}
{#if !results[activeTab]?.length} {#if !results[activeTab]?.length}
<tr <tr
><td ><td
colspan="4" colspan="5"
class="p-10 text-center text-gray-600" class="p-20 text-center text-gray-600 bg-gray-900/10"
>No items found.</td >
<i class="bi bi-shield-lock text-4xl mb-4 block opacity-10"></i>
NO ANOMALIES DETECTED IN THIS SECTOR.
</td
></tr ></tr
> >
{/if} {/if}
@ -221,3 +519,28 @@
</div> </div>
</div> </div>
</div> </div>
<style>
.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;
}
</style>

View file

@ -70,6 +70,12 @@ const { title } = Astro.props;
> >
<i class="bi bi-film mr-1"></i> Transcoding <i class="bi bi-film mr-1"></i> Transcoding
</a> </a>
<button
id="logout-btn"
class="text-gray-500 hover:text-neon-pink hover:bg-white/5 px-3 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer"
>
<i class="bi bi-box-arrow-right mr-1"></i> Logout
</button>
</div> </div>
</div> </div>
<div> <div>
@ -95,5 +101,16 @@ const { title } = Astro.props;
> >
<p>TUBESORTER // SYSTEM_V2 // BUN_POWERED</p> <p>TUBESORTER // SYSTEM_V2 // BUN_POWERED</p>
</footer> </footer>
<script>
const logoutBtn = document.getElementById('logout-btn');
logoutBtn?.addEventListener('click', async () => {
try {
const res = await fetch('/api/auth/logout', { method: 'POST' });
if (res.ok) window.location.href = '/login';
} catch (err) {
console.error('Logout failed', err);
}
});
</script>
</body> </body>
</html> </html>

101
ui/src/pages/login.astro Normal file
View file

@ -0,0 +1,101 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Login">
<div class="min-h-[70vh] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 glass-panel p-8 rounded-2xl relative overflow-hidden group">
<!-- Glow Effect -->
<div class="absolute -top-24 -left-24 w-48 h-48 bg-neon-cyan/20 blur-[80px] rounded-full pointer-events-none group-hover:bg-neon-cyan/30 transition-colors"></div>
<div class="absolute -bottom-24 -right-24 w-48 h-48 bg-neon-pink/20 blur-[80px] rounded-full pointer-events-none group-hover:bg-neon-pink/30 transition-colors"></div>
<div class="relative z-10">
<div class="text-center">
<i class="bi bi-shield-lock text-6xl neon-text-cyan animate-pulse inline-block mb-4"></i>
<h2 class="mt-2 text-3xl font-extrabold italic tracking-tighter text-white">
ACCESS_REQUIRED
</h2>
<p class="mt-2 text-sm text-gray-400 font-mono">
// PLEASE_IDENTIFY_YOURSELF
</p>
</div>
<form id="login-form" class="mt-8 space-y-6">
<div class="space-y-4">
<div>
<label for="username" class="sr-only">Username</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-500">
<i class="bi bi-person-fill"></i>
</span>
<input id="username" name="username" type="text" required
class="appearance-none relative block w-full px-10 py-3 border border-gray-800 bg-cyber-darker/50 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-neon-cyan/50 focus:border-neon-cyan transition-all placeholder-gray-600 sm:text-sm"
placeholder="Username" />
</div>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-500">
<i class="bi bi-key-fill"></i>
</span>
<input id="password" name="password" type="password" required
class="appearance-none relative block w-full px-10 py-3 border border-gray-800 bg-cyber-darker/50 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-neon-pink/50 focus:border-neon-pink transition-all placeholder-gray-600 sm:text-sm"
placeholder="Password" />
</div>
</div>
</div>
<div id="error-message" class="hidden text-neon-pink text-xs font-mono text-center animate-bounce">
// ERROR: INVALID_CREDENTIALS
</div>
<div>
<button type="submit" id="submit-btn"
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-bold rounded-lg text-cyber-dark bg-gradient-to-r from-neon-cyan to-neon-pink hover:from-white hover:to-white transition-all duration-300 shadow-[0_0_15px_rgba(0,243,255,0.3)] hover:shadow-[0_0_25px_rgba(255,255,255,0.5)] focus:outline-none uppercase italic tracking-widest">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<i class="bi bi-unlock-fill text-cyber-dark group-hover:animate-bounce"></i>
</span>
Authenticate
</button>
</div>
</form>
</div>
</div>
</div>
</Layout>
<script>
const form = document.getElementById('login-form');
const errorMsg = document.getElementById('error-message');
const submitBtn = document.getElementById('submit-btn');
form?.addEventListener('submit', async (e) => {
e.preventDefault();
errorMsg?.classList.add('hidden');
const formData = new FormData(form as HTMLFormElement);
const data = Object.fromEntries(formData.entries());
if (submitBtn) submitBtn.innerHTML = '<i class="bi bi-arrow-repeat animate-spin mr-2"></i> Processing...';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.ok) {
window.location.href = '/';
} else {
errorMsg?.classList.remove('hidden');
if (submitBtn) submitBtn.innerHTML = 'Authenticate';
}
} catch (err) {
console.error(err);
errorMsg?.classList.remove('hidden');
if (submitBtn) submitBtn.innerHTML = 'Authenticate';
}
});
</script>