Compare commits
1 commit
feature/ui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 003f6a4dd9 |
7 changed files with 213 additions and 961 deletions
|
|
@ -2,7 +2,7 @@ name: Docker Build
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" , "feature/ui-login-rework"]
|
||||
branches: [ "main" ]
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- '.gitignore'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
559
ta_symlink.py
559
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
|
||||
log(f" ❌ Failed to delete {video_dir.name}: {e}")
|
||||
else:
|
||||
log(f" ⚠️ SKIPPING {video_dir.name} - {reason}")
|
||||
skipped_count += 1
|
||||
|
||||
# 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.")
|
||||
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
|
||||
# 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
|
||||
|
||||
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}")
|
||||
# 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
|
||||
# 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()}")
|
||||
|
||||
# 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}"
|
||||
|
||||
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}")
|
||||
log(f" ❌ Failed to move {sanitized_channel_name} from old location: {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))
|
||||
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
|
||||
|
||||
processed_videos.append({
|
||||
"video_id": video_id,
|
||||
"title": meta["title"],
|
||||
"channel": meta["channel_name"],
|
||||
"published": meta["published"],
|
||||
"symlink": str(dest_file)
|
||||
})
|
||||
# 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():
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,39 +2,12 @@
|
|||
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<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() {
|
||||
scanning = true;
|
||||
try {
|
||||
|
|
@ -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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
>
|
||||
<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)] overflow-hidden"
|
||||
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)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="p-5 border-b border-gray-800 flex justify-between items-center bg-gray-900/40"
|
||||
class="p-5 border-b border-gray-800 flex justify-between items-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
on:click={() => dispatch("close")}
|
||||
class="text-gray-500 hover:text-white transition-colors"
|
||||
class="text-gray-500 hover:text-white"
|
||||
aria-label="Close"><i class="bi bi-x-lg"></i></button
|
||||
>
|
||||
</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 -->
|
||||
<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
|
||||
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}
|
||||
disabled={scanning}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Run System Scan"}
|
||||
</button>
|
||||
|
||||
{#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 class="p-4 bg-gray-900/50 flex justify-between items-center">
|
||||
<button
|
||||
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-colors"
|
||||
on:click={startScan}
|
||||
disabled={scanning}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Run System Scan"}
|
||||
</button>
|
||||
<div class="text-xs text-mono text-gray-500">Status: {status}</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-800 px-4 bg-gray-900/20">
|
||||
{#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab}
|
||||
<div class="flex border-b border-gray-800 px-4">
|
||||
{#each ["unindexed", "rescue", "redundant", "lost"] as tab}
|
||||
<button
|
||||
class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-all flex items-center gap-2
|
||||
class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-colors flex items-center gap-2
|
||||
{activeTab === tab
|
||||
? 'border-neon-cyan text-neon-cyan bg-neon-cyan/5'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300 hover:bg-white/5'}"
|
||||
on:click={() => { activeTab = tab as any; selectedPaths = new Set(); }}
|
||||
? 'border-neon-cyan text-neon-cyan'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = tab as any)}
|
||||
>
|
||||
{tab}
|
||||
{#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
|
||||
>
|
||||
{/if}
|
||||
<span class="bg-gray-800 text-xs px-1.5 rounded-full"
|
||||
>{results[tab]?.length || 0}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-grow overflow-y-auto p-4 bg-black/30">
|
||||
{#if activeTab === "channels"}
|
||||
<div class="flex flex-col h-full gap-4">
|
||||
<!-- 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)}
|
||||
>
|
||||
<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)}
|
||||
{#if scanning && (!results[activeTab] || results[activeTab].length === 0)}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
|
||||
class="flex items-center justify-center h-full text-neon-cyan animate-pulse"
|
||||
>
|
||||
<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>
|
||||
Scanning...
|
||||
</div>
|
||||
{:else}
|
||||
<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">
|
||||
<table class="w-full text-left text-xs text-gray-300 font-mono">
|
||||
<thead>
|
||||
<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">ID</th>
|
||||
<th class="p-3">Filename / Target Path</th>
|
||||
<th class="p-3">Info</th>
|
||||
<th class="p-3">Video ID</th>
|
||||
<th class="p-3">Filename / Path</th>
|
||||
<th class="p-3">Size/Info</th>
|
||||
<th class="p-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each results[activeTab] || [] as item}
|
||||
<tr
|
||||
class="border-b border-gray-800/50 hover:bg-white/5 transition-colors {selectedPaths.has(item.path) ? 'bg-neon-cyan/5' : ''}"
|
||||
class="border-b border-gray-800/50 hover:bg-white/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"
|
||||
<td class="p-3 text-neon-pink"
|
||||
>{item.video_id}</td
|
||||
>
|
||||
<td
|
||||
class="p-3 truncate max-w-[350px] group relative"
|
||||
class="p-3 truncate max-w-[300px]"
|
||||
title={item.path}
|
||||
>
|
||||
<span class="group-hover:text-white transition-colors">{item.filename || item.path}</span>
|
||||
</td
|
||||
>{item.filename || item.path}</td
|
||||
>
|
||||
<td class="p-3"
|
||||
>{item.size_mb
|
||||
|
|
@ -463,53 +172,46 @@
|
|||
: item.ta_source || "-"}</td
|
||||
>
|
||||
<td class="p-3 text-right">
|
||||
<div class="flex justify-end gap-3">
|
||||
{#if activeTab === "unindexed"}
|
||||
<button
|
||||
class="text-neon-green hover:underline flex items-center gap-1 transition-all hover:scale-105"
|
||||
on:click={() =>
|
||||
recoverFile(item.path)}
|
||||
><i class="bi bi-plus-circle"></i> RECOVER</button
|
||||
>
|
||||
{:else if activeTab === "redundant"}
|
||||
<button
|
||||
class="text-red-500 hover:underline font-bold transition-all hover:scale-105 disabled:opacity-30"
|
||||
on:click={() =>
|
||||
deleteFile(item.path)}
|
||||
disabled={targetRO || (destructMode && sourceRO)}
|
||||
><i class="bi bi-trash"></i> DELETE</button
|
||||
>
|
||||
{:else if activeTab === "lost"}
|
||||
<button
|
||||
class="text-neon-yellow hover:underline flex items-center gap-1"
|
||||
><i class="bi bi-lightning-fill"></i> FORCE</button
|
||||
>
|
||||
<button
|
||||
class="text-red-500 hover:underline font-bold disabled:opacity-30"
|
||||
on:click={() =>
|
||||
deleteFile(item.path)}
|
||||
disabled={targetRO || (destructMode && sourceRO)}
|
||||
><i class="bi bi-trash"></i> DELETE</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="text-neon-pink hover:underline flex items-center gap-1"
|
||||
><i class="bi bi-rescue-ambulance"></i> RESCUE</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if activeTab === "unindexed"}
|
||||
<button
|
||||
class="text-neon-green hover:underline"
|
||||
on:click={() =>
|
||||
recoverFile(item.path)}
|
||||
>Recover</button
|
||||
>
|
||||
{:else if activeTab === "redundant"}
|
||||
<button
|
||||
class="text-red-500 hover:underline"
|
||||
on:click={() =>
|
||||
deleteFile(item.path)}
|
||||
>Delete</button
|
||||
>
|
||||
{:else if activeTab === "lost"}
|
||||
<button
|
||||
class="text-neon-yellow hover:underline mr-2"
|
||||
>Force</button
|
||||
>
|
||||
<button
|
||||
class="text-red-500 hover:underline"
|
||||
on:click={() =>
|
||||
deleteFile(item.path)}
|
||||
>Delete</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="text-neon-pink hover:underline"
|
||||
>Rescue</button
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if !results[activeTab]?.length}
|
||||
<tr
|
||||
><td
|
||||
colspan="5"
|
||||
class="p-20 text-center text-gray-600 bg-gray-900/10"
|
||||
>
|
||||
<i class="bi bi-shield-lock text-4xl mb-4 block opacity-10"></i>
|
||||
NO ANOMALIES DETECTED IN THIS SECTOR.
|
||||
</td
|
||||
colspan="4"
|
||||
class="p-10 text-center text-gray-600"
|
||||
>No items found.</td
|
||||
></tr
|
||||
>
|
||||
{/if}
|
||||
|
|
@ -519,28 +221,3 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -70,12 +70,6 @@ const { title } = Astro.props;
|
|||
>
|
||||
<i class="bi bi-film mr-1"></i> Transcoding
|
||||
</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>
|
||||
|
|
@ -101,16 +95,5 @@ const { title } = Astro.props;
|
|||
>
|
||||
<p>TUBESORTER // SYSTEM_V2 // BUN_POWERED</p>
|
||||
</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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
---
|
||||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue