Compare commits
18 commits
main
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
| 996e074296 | |||
| b7ca75b57e | |||
| 568601181d | |||
| 581de707d7 | |||
| 04c7c5ec5e | |||
| 6e0d081799 | |||
| a296c932f7 | |||
| b57aecf7cb | |||
| e99d21fac7 | |||
| 8876469c43 | |||
| 88bc8229c9 | |||
| 45a1f0ae93 | |||
| 85f7a18883 | |||
| 62428c313b | |||
| 29c3339c39 | |||
| dd25df4bdc | |||
| 8a9f8fbb35 | |||
| 394c27401d |
6 changed files with 962 additions and 214 deletions
|
|
@ -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'
|
||||||
|
|
|
||||||
567
ta_symlink.py
567
ta_symlink.py
|
|
@ -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():
|
||||||
|
|
||||||
# Walk top-down
|
|
||||||
for channel_dir in TARGET_DIR.iterdir():
|
|
||||||
if not channel_dir.is_dir():
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for video_dir in channel_dir.iterdir():
|
# Walk top-down: Channels
|
||||||
if not video_dir.is_dir():
|
for channel_dir in root.iterdir():
|
||||||
|
if not channel_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if "+00:00" in video_dir.name:
|
# Videos
|
||||||
# Check safety
|
for video_dir in list(channel_dir.iterdir()): # List to allow removal
|
||||||
safe_to_delete = True
|
if not video_dir.is_dir():
|
||||||
reason = ""
|
continue
|
||||||
|
|
||||||
|
# Check if it contains any real files
|
||||||
|
safe_to_delete = True
|
||||||
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,109 +837,101 @@ 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("*.*"):
|
||||||
video_id = video_file.stem
|
if not is_video(video_file):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Robust ID Extraction
|
||||||
|
video_id = extract_id_from_filename(video_file.name)
|
||||||
|
if not video_id:
|
||||||
|
# Fallback for old simple-name format
|
||||||
|
video_id = video_file.stem
|
||||||
|
|
||||||
# Lookup in local map
|
# 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"])
|
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
|
||||||
|
|
||||||
# Determine target root
|
# Migration Logic
|
||||||
is_hidden = meta["channel_name"] in hidden_channels
|
wrong_channel_dir = other_root / sanitized_channel_name
|
||||||
target_root = HIDDEN_DIR if is_hidden else TARGET_DIR
|
correct_channel_dir = target_root / sanitized_channel_name
|
||||||
other_root = TARGET_DIR if is_hidden else HIDDEN_DIR
|
|
||||||
|
|
||||||
# Check if channel exists in the WRONG place and MOVE it (Migration/Toggle)
|
if wrong_channel_dir.exists():
|
||||||
wrong_channel_dir = other_root / sanitized_channel_name
|
try:
|
||||||
correct_channel_dir = target_root / sanitized_channel_name
|
if not correct_channel_dir.exists():
|
||||||
|
shutil.move(str(wrong_channel_dir), str(correct_channel_dir))
|
||||||
# DEBUG LOGGING (Temporary)
|
log(f" [MOVE] Migrated {sanitized_channel_name} to {target_root.name}")
|
||||||
if meta["channel_name"] in hidden_channels:
|
|
||||||
log(f"DEBUG: Checking {meta['channel_name']} (Hidden). Wrong Dir: {wrong_channel_dir}, Exists? {wrong_channel_dir.exists()}")
|
|
||||||
|
|
||||||
|
|
||||||
if wrong_channel_dir.exists():
|
|
||||||
try:
|
|
||||||
# If destination already exists, we have a conflict.
|
|
||||||
# Strategy: Merge move?
|
|
||||||
# Simplest robust way:
|
|
||||||
# 1. Ensure dest exists
|
|
||||||
# 2. Move contents?
|
|
||||||
# Or just shutil.move(src, dst) which works if dst doesn't exist.
|
|
||||||
|
|
||||||
if not correct_channel_dir.exists():
|
|
||||||
shutil.move(str(wrong_channel_dir), str(correct_channel_dir))
|
|
||||||
log(f" [MOVE] Moved {sanitized_channel_name} to {target_root.name} (Status Change)")
|
|
||||||
else:
|
|
||||||
# Destination exists. We must merge.
|
|
||||||
# Move items one by one.
|
|
||||||
for item in wrong_channel_dir.iterdir():
|
|
||||||
dest_item = correct_channel_dir / item.name
|
|
||||||
if not dest_item.exists():
|
|
||||||
shutil.move(str(item), str(dest_item))
|
|
||||||
else:
|
|
||||||
# Conflict. If it's a folder, we could recurse, but let's just log warning and skip?
|
|
||||||
# If it's a file/symlink, we skip (it will be regenerated/verified later by the loop)
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Now remove the empty source dir
|
|
||||||
try:
|
|
||||||
wrong_channel_dir.rmdir()
|
|
||||||
except OSError:
|
|
||||||
log(f" ⚠️ Could not remove old dir {wrong_channel_dir} (not empty?)")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log(f" ❌ Failed to move {sanitized_channel_name} from old location: {e}")
|
|
||||||
|
|
||||||
channel_dir = target_root / sanitized_channel_name
|
|
||||||
channel_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
sanitized_title = sanitize(meta["title"])
|
|
||||||
folder_name = f"{meta['published']} - {sanitized_title}"
|
|
||||||
video_dir = channel_dir / folder_name
|
|
||||||
video_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
actual_file = next(channel_path.glob(f"{video_id}.*"), None)
|
|
||||||
if not actual_file:
|
|
||||||
continue
|
|
||||||
host_path_root = Path("/mnt/user/tubearchives/bp")
|
|
||||||
host_source_path = host_path_root / actual_file.relative_to(SOURCE_DIR)
|
|
||||||
dest_file = video_dir / f"video{actual_file.suffix}"
|
|
||||||
try:
|
|
||||||
if dest_file.exists():
|
|
||||||
if dest_file.is_symlink():
|
|
||||||
current_target = Path(os.readlink(dest_file))
|
|
||||||
if current_target.resolve() != host_source_path.resolve():
|
|
||||||
dest_file.unlink()
|
|
||||||
os.symlink(host_source_path, dest_file)
|
|
||||||
log(f" [FIX] Relinked: {folder_name}")
|
|
||||||
new_links += 1
|
|
||||||
else:
|
else:
|
||||||
verified_links += 1
|
for item in list(wrong_channel_dir.iterdir()):
|
||||||
|
dest_item = correct_channel_dir / item.name
|
||||||
|
if not dest_item.exists():
|
||||||
|
shutil.move(str(item), str(dest_item))
|
||||||
|
try:
|
||||||
|
wrong_channel_dir.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log(f" ❌ Migration error for {sanitized_channel_name}: {e}")
|
||||||
|
|
||||||
|
# Folder Creation (Delay until link check)
|
||||||
|
channel_dir = target_root / sanitized_channel_name
|
||||||
|
|
||||||
|
# Stream Organization
|
||||||
|
if meta.get("is_live"):
|
||||||
|
channel_dir = channel_dir / "#streams"
|
||||||
|
|
||||||
|
sanitized_title = sanitize(meta["title"])
|
||||||
|
folder_name = f"{meta['published']} - {sanitized_title}"
|
||||||
|
video_dir = channel_dir / folder_name
|
||||||
|
|
||||||
|
host_path_root = HOST_SOURCE_BASE
|
||||||
|
host_source_path = host_path_root / video_file.relative_to(SOURCE_DIR)
|
||||||
|
dest_file = video_dir / f"video{video_file.suffix}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
link_success = False
|
||||||
|
if dest_file.exists():
|
||||||
|
if dest_file.is_symlink():
|
||||||
|
current_target = Path(os.readlink(dest_file))
|
||||||
|
if current_target.resolve() != host_source_path.resolve():
|
||||||
|
dest_file.unlink()
|
||||||
|
os.symlink(host_source_path, dest_file)
|
||||||
|
log(f" [FIX] Relinked: {folder_name}")
|
||||||
|
new_links += 1
|
||||||
|
link_success = True
|
||||||
|
else:
|
||||||
|
verified_links += 1
|
||||||
|
link_success = True
|
||||||
else:
|
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,
|
||||||
"title": meta["title"],
|
"title": meta["title"],
|
||||||
"channel": meta["channel_name"],
|
"channel": meta["channel_name"],
|
||||||
"published": meta["published"],
|
"published": meta["published"],
|
||||||
"symlink": str(dest_file)
|
"symlink": str(dest_file)
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return str(e)
|
return str(e)
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<button
|
<div class="flex items-center gap-3">
|
||||||
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-colors"
|
<button
|
||||||
on:click={startScan}
|
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"
|
||||||
disabled={scanning}
|
on:click={startScan}
|
||||||
>
|
disabled={scanning}
|
||||||
{scanning ? "Scanning..." : "Run System Scan"}
|
>
|
||||||
</button>
|
{scanning ? "Scanning..." : "Run System Scan"}
|
||||||
<div class="text-xs text-mono text-gray-500">Status: {status}</div>
|
</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>
|
</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'}
|
||||||
>{results[tab]?.length || 0}</span
|
<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}
|
||||||
</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 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)}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center h-full text-neon-cyan animate-pulse"
|
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
|
||||||
>
|
>
|
||||||
Scanning...
|
<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">
|
||||||
{#if activeTab === "unindexed"}
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
{#if activeTab === "unindexed"}
|
||||||
class="text-neon-green hover:underline"
|
<button
|
||||||
on:click={() =>
|
class="text-neon-green hover:underline flex items-center gap-1 transition-all hover:scale-105"
|
||||||
recoverFile(item.path)}
|
on:click={() =>
|
||||||
>Recover</button
|
recoverFile(item.path)}
|
||||||
>
|
><i class="bi bi-plus-circle"></i> RECOVER</button
|
||||||
{:else if activeTab === "redundant"}
|
>
|
||||||
<button
|
{:else if activeTab === "redundant"}
|
||||||
class="text-red-500 hover:underline"
|
<button
|
||||||
on:click={() =>
|
class="text-red-500 hover:underline font-bold transition-all hover:scale-105 disabled:opacity-30"
|
||||||
deleteFile(item.path)}
|
on:click={() =>
|
||||||
>Delete</button
|
deleteFile(item.path)}
|
||||||
>
|
disabled={targetRO || (destructMode && sourceRO)}
|
||||||
{:else if activeTab === "lost"}
|
><i class="bi bi-trash"></i> DELETE</button
|
||||||
<button
|
>
|
||||||
class="text-neon-yellow hover:underline mr-2"
|
{:else if activeTab === "lost"}
|
||||||
>Force</button
|
<button
|
||||||
>
|
class="text-neon-yellow hover:underline flex items-center gap-1"
|
||||||
<button
|
><i class="bi bi-lightning-fill"></i> FORCE</button
|
||||||
class="text-red-500 hover:underline"
|
>
|
||||||
on:click={() =>
|
<button
|
||||||
deleteFile(item.path)}
|
class="text-red-500 hover:underline font-bold disabled:opacity-30"
|
||||||
>Delete</button
|
on:click={() =>
|
||||||
>
|
deleteFile(item.path)}
|
||||||
{:else}
|
disabled={targetRO || (destructMode && sourceRO)}
|
||||||
<button
|
><i class="bi bi-trash"></i> DELETE</button
|
||||||
class="text-neon-pink hover:underline"
|
>
|
||||||
>Rescue</button
|
{:else}
|
||||||
>
|
<button
|
||||||
{/if}
|
class="text-neon-pink hover:underline flex items-center gap-1"
|
||||||
|
><i class="bi bi-rescue-ambulance"></i> RESCUE</button
|
||||||
|
>
|
||||||
|
{/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>
|
||||||
|
|
|
||||||
|
|
@ -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
101
ui/src/pages/login.astro
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue