Compare commits

...
Sign in to create a new pull request.

18 commits

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

View file

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

View file

@ -9,7 +9,7 @@ import ipaddress
import shutil
from datetime import datetime
from functools import wraps
from flask import Flask, jsonify, render_template, request, abort, Response, send_from_directory
from flask import Flask, jsonify, render_template, request, abort, Response, send_from_directory, session, redirect, url_for
# Load config from environment variables
API_URL = os.getenv("API_URL", "http://localhost:8457/api")
@ -23,11 +23,14 @@ 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
@ -55,6 +58,7 @@ 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 (
@ -66,6 +70,17 @@ 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
@ -95,6 +110,48 @@ 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)
@ -103,6 +160,12 @@ 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
@ -319,7 +382,9 @@ def fetch_all_metadata():
video_map[vid_id] = {
"title": title,
"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
@ -343,53 +408,55 @@ def fetch_all_metadata():
def cleanup_old_folders():
"""
Scans TARGET_DIR for folders containing '+00:00'.
Safely deletes them ONLY if they contain no real files (only symlinks or empty).
Scans both TARGET_DIR and HIDDEN_DIR for empty or orphaned folders.
Safely deletes them if they contain no real files.
"""
log("🧹 Starting cleanup. Scanning ONLY for folders containing '+00:00'...")
log("🧹 Starting aggressive cleanup of empty folders...")
cleaned_count = 0
skipped_count = 0
if not TARGET_DIR.exists():
return
for root in [TARGET_DIR, HIDDEN_DIR]:
if not root.exists():
continue
# Walk top-down
for channel_dir in TARGET_DIR.iterdir():
# Walk top-down: Channels
for channel_dir in root.iterdir():
if not channel_dir.is_dir():
continue
for video_dir in channel_dir.iterdir():
# Videos
for video_dir in list(channel_dir.iterdir()): # List to allow removal
if not video_dir.is_dir():
continue
if "+00:00" in video_dir.name:
# Check safety
# Check if it contains any real files
safe_to_delete = True
reason = ""
for item in video_dir.iterdir():
if not item.is_symlink():
# Found a real file! Unsafe!
safe_to_delete = False
reason = "Contains real files"
break
if safe_to_delete:
try:
# Remove all symlinks first
for item in video_dir.iterdir():
for item in list(video_dir.iterdir()):
item.unlink()
# Remove directory
# Remove video directory
video_dir.rmdir()
log(f" [DELETED] {video_dir.name}")
log(f" [DELETED VIDEO] {video_dir.name}")
cleaned_count += 1
except Exception as e:
log(f" ❌ Failed to delete {video_dir.name}: {e}")
else:
log(f" ⚠️ SKIPPING {video_dir.name} - {reason}")
skipped_count += 1
pass # Likely not empty
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():
"""
@ -504,9 +571,12 @@ def scan_for_unindexed_videos():
"lost": []
}
# Helper to check if file is video
def is_video(f):
return f.suffix.lower() in ['.mp4', '.mkv', '.webm', '.mov']
results = {
"unindexed": [],
"redundant": [],
"rescue": [],
"lost": []
}
# --- Scan SOURCE_DIR (Standard Orphan Check) ---
if SOURCE_DIR.exists():
@ -767,74 +837,63 @@ 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
# Lookup in local map
meta = video_map.get(video_id)
if not meta:
continue
sanitized_channel_name = sanitize(meta["channel_name"])
# Determine target root
sanitized_channel_name = sanitize(meta["channel_name"])
is_hidden = meta["channel_name"] in hidden_channels
target_root = HIDDEN_DIR if is_hidden else TARGET_DIR
other_root = TARGET_DIR if is_hidden else HIDDEN_DIR
# Check if channel exists in the WRONG place and MOVE it (Migration/Toggle)
# Migration Logic
wrong_channel_dir = other_root / sanitized_channel_name
correct_channel_dir = target_root / sanitized_channel_name
# DEBUG LOGGING (Temporary)
if meta["channel_name"] in hidden_channels:
log(f"DEBUG: Checking {meta['channel_name']} (Hidden). Wrong Dir: {wrong_channel_dir}, Exists? {wrong_channel_dir.exists()}")
if wrong_channel_dir.exists():
try:
# If destination already exists, we have a conflict.
# Strategy: Merge move?
# Simplest robust way:
# 1. Ensure dest exists
# 2. Move contents?
# Or just shutil.move(src, dst) which works if dst doesn't exist.
if not correct_channel_dir.exists():
shutil.move(str(wrong_channel_dir), str(correct_channel_dir))
log(f" [MOVE] Moved {sanitized_channel_name} to {target_root.name} (Status Change)")
log(f" [MOVE] Migrated {sanitized_channel_name} to {target_root.name}")
else:
# Destination exists. We must merge.
# Move items one by one.
for item in wrong_channel_dir.iterdir():
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))
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?)")
pass
except Exception as e:
log(f"Failed to move {sanitized_channel_name} from old location: {e}")
log(f" ❌ Migration error for {sanitized_channel_name}: {e}")
# Folder Creation (Delay until link check)
channel_dir = target_root / sanitized_channel_name
channel_dir.mkdir(parents=True, exist_ok=True)
# 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
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}"
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))
@ -843,25 +902,28 @@ def process_videos():
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:
# It's a file or something else, replace it? No, unsafe.
pass
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
except Exception:
pass
link_success = True
except Exception as e:
log(f" ❌ Link error for {folder_name}: {e}")
# Store in database
conn.execute("""
INSERT OR REPLACE INTO videos
(video_id, title, channel, published, symlink, status)
VALUES (?, ?, ?, ?, ?, 'linked')
(video_id, title, channel, published, symlink, is_live, status)
VALUES (?, ?, ?, ?, ?, ?, 'linked')
""", (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({
"video_id": video_id,
@ -918,22 +980,44 @@ def check_auth(username, password):
"""Checks whether a username/password combination is valid."""
return username == UI_USERNAME and password == UI_PASSWORD
def authenticate():
"""Sends a 401 response that enables basic auth"""
return Response(
'Could not verify your access level for that URL.\n'
'You have to login with proper credentials', 401,
{'WWW-Authenticate': 'Basic realm="Login Required"'})
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_auth(auth.username, auth.password):
return authenticate()
if not session.get('logged_in'):
if request.path.startswith('/api/'):
return jsonify({"error": "Unauthorized"}), 401
return redirect('/login')
return f(*args, **kwargs)
return decorated
@app.route("/api/auth/login", methods=["POST"])
def api_login():
data = request.json
username = data.get("username")
password = data.get("password")
if check_auth(username, password):
session['logged_in'] = True
session['username'] = username
return jsonify({"success": True})
return jsonify({"error": "Invalid credentials"}), 401
@app.route("/api/auth/logout", methods=["POST"])
def api_logout():
session.clear()
return jsonify({"success": True})
@app.route("/api/auth/status")
def api_auth_status():
return jsonify({
"logged_in": session.get('logged_in', False),
"username": session.get('username')
})
@app.route("/login")
def login_page():
return send_from_directory(app.static_folder, 'login/index.html')
@app.route("/")
@requires_auth
def index():
@ -1131,6 +1215,186 @@ 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():
@ -1173,6 +1437,8 @@ 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}")
@ -1181,6 +1447,43 @@ 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():

View file

@ -24,6 +24,10 @@
async function fetchData() {
try {
const res = await fetch("/api/status");
if (res.status === 401) {
window.location.href = "/login";
return;
}
if (!res.ok) throw new Error("Failed to fetch status");
const data = await res.json();

View file

@ -2,12 +2,39 @@
import { createEventDispatcher, onMount, onDestroy } from "svelte";
const dispatch = createEventDispatcher();
let activeTab: "unindexed" | "rescue" | "redundant" | "lost" = "unindexed";
let activeTab: "unindexed" | "rescue" | "redundant" | "lost" | "channels" = "unindexed";
let scanning = false;
let status = "idle";
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
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 {
@ -43,12 +70,38 @@
}, 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", {
@ -59,112 +112,350 @@
const d = await res.json();
if (!isBatch) {
alert(d.message);
startScan();
} // Refresh
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
else startScan();
}
} catch (e) {
alert(e);
}
}
async function deleteFile(path: string) {
if (!confirm("Delete file? This cannot be undone.")) return;
let msg = "Delete file? This cannot be undone.";
if (destructMode) {
msg = "☢️ DESTRUCT MODE ACTIVE ☢️\n\nThis will delete BOTH the target folder AND the source file in your archive.\n\nAre you absolutely sure?";
}
if (!confirm(msg)) return;
if (destructMode && !confirm("FINAL WARNING: This is IRREVERSIBLE. Delete source file now?")) return;
try {
const res = await fetch("/api/recovery/delete", {
const res = await fetch("/api/recovery/delete-batch", {
method: "POST",
body: JSON.stringify({ filepath: path }),
body: JSON.stringify({
filepaths: [path],
destruct_mode: destructMode
}),
headers: { "Content-Type": "application/json" },
});
const d = await res.json();
if (d.success) {
if (d.success_count > 0) {
alert("Deleted.");
startScan();
} else alert("Error: " + d.error);
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
else startScan();
} else {
const err = d.errors?.[0] || "Unknown error";
alert(`Error deleting file: ${err}`);
}
} 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)]"
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 -->
<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">
<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"
class="text-gray-500 hover:text-white transition-colors"
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 justify-between items-center">
<div class="p-4 bg-gray-900/50 flex flex-wrap gap-4 justify-between items-center">
<div class="flex items-center gap-3">
<button
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-colors"
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-all transform active:scale-95 disabled:opacity-50"
on:click={startScan}
disabled={scanning}
>
{scanning ? "Scanning..." : "Run System Scan"}
</button>
<div class="text-xs text-mono text-gray-500">Status: {status}</div>
{#if selectedPaths.size > 0}
<button
class="px-6 py-2 rounded font-bold text-white bg-red-600 hover:bg-red-500 transition-all transform active:scale-95 flex items-center gap-2 shadow-[0_0_15px_rgba(220,38,38,0.4)]"
on:click={deleteSelected}
disabled={targetRO || (destructMode && sourceRO)}
>
<i class="bi bi-trash"></i> Delete Selected ({selectedPaths.size})
</button>
{/if}
</div>
<div class="flex items-center gap-6">
<!-- Destruct Mode Toggle -->
<label class="relative inline-flex items-center cursor-pointer group">
<input type="checkbox" bind:checked={destructMode} class="sr-only peer">
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-red-600"></div>
<span class="ml-3 text-sm font-medium text-gray-300 group-hover:text-white transition-colors flex items-center gap-2">
Destruct Mode
{#if destructMode}
<i class="bi bi-exclamation-triangle-fill text-red-500 animate-pulse"></i>
{/if}
</span>
</label>
<div class="text-xs text-mono text-gray-500">Status: <span class="text-neon-cyan underline decoration-dotted">{status}</span></div>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-gray-800 px-4">
{#each ["unindexed", "rescue", "redundant", "lost"] as tab}
<div class="flex border-b border-gray-800 px-4 bg-gray-900/20">
{#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab}
<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
? 'border-neon-cyan text-neon-cyan'
: 'border-transparent text-gray-500 hover:text-gray-300'}"
on:click={() => (activeTab = tab as any)}
? '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(); }}
>
{tab}
<span class="bg-gray-800 text-xs px-1.5 rounded-full"
{#if tab !== 'channels'}
<span class="bg-gray-800 text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.2rem] text-center"
>{results[tab]?.length || 0}</span
>
{/if}
</button>
{/each}
</div>
<!-- Content -->
<div class="flex-grow overflow-y-auto p-4 bg-black/30">
{#if scanning && (!results[activeTab] || results[activeTab].length === 0)}
<div
class="flex items-center justify-center h-full text-neon-cyan animate-pulse"
{#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)}
>
Scanning...
<option value="">-- Choose a Channel --</option>
{#each channels as channel}
<option value={channel}>{channel}</option>
{/each}
</select>
</div>
<button
class="p-2.5 rounded bg-gray-800 text-white hover:bg-gray-700 transition-colors border border-gray-700"
on:click={fetchChannels}
title="Refresh Channel List"
>
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<!-- Video Table -->
<div class="flex-grow overflow-y-auto border border-gray-800 rounded bg-gray-900/20">
{#if searchingVideos}
<div class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4">
<div class="w-8 h-8 border-2 border-neon-cyan border-t-transparent rounded-full animate-spin"></div>
<span class="text-xs font-mono animate-pulse">QUERYING TA DATABASE...</span>
</div>
{:else if channelVideos.length > 0}
<table class="w-full text-left text-[11px] text-gray-300 font-mono border-collapse">
<thead class="sticky top-0 bg-gray-900 z-10">
<tr class="text-gray-500 border-b border-gray-800">
<th class="p-3 w-10">
<input
type="checkbox"
checked={allSelected}
on:change={toggleAll}
class="accent-neon-cyan"
>
</th>
<th class="p-3">Video ID</th>
<th class="p-3">Title</th>
<th class="p-3 text-right">Action</th>
</tr>
</thead>
<tbody>
{#each channelVideos as item}
<tr class="border-b border-gray-800/50 hover:bg-white/5 transition-colors {selectedPaths.has(item.path) ? 'bg-neon-cyan/5' : ''}">
<td class="p-3 text-center">
<input
type="checkbox"
checked={selectedPaths.has(item.path)}
on:change={() => toggleSelect(item.path)}
class="accent-neon-cyan"
>
</td>
<td class="p-3 text-neon-pink font-bold">{item.video_id}</td>
<td class="p-3 truncate max-w-[400px]" title={item.title}>{item.title}</td>
<td class="p-3 text-right">
<button
class="text-red-500 hover:text-red-400 font-bold transition-colors disabled:opacity-30"
on:click={() => deleteFile(item.path)}
disabled={targetRO || (destructMode && sourceRO)}
>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="flex flex-col items-center justify-center h-full text-gray-700 gap-3">
<i class="bi bi-broadcast text-4xl opacity-10"></i>
<span class="text-xs uppercase tracking-widest font-bold">No channel selected</span>
</div>
{/if}
</div>
</div>
{:else if scanning && (!results[activeTab] || results[activeTab].length === 0)}
<div
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
>
<div class="w-12 h-12 border-4 border-neon-cyan border-t-transparent rounded-full animate-spin"></div>
<div class="animate-pulse font-mono tracking-widest">SYSTEM WIDE SCAN IN PROGRESS...</div>
</div>
{:else}
<table class="w-full text-left text-xs text-gray-300 font-mono">
<thead>
<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">Video ID</th>
<th class="p-3">Filename / Path</th>
<th class="p-3">Size/Info</th>
<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 text-right">Action</th>
</tr>
</thead>
<tbody>
{#each results[activeTab] || [] as item}
<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
>
<td
class="p-3 truncate max-w-[300px]"
class="p-3 truncate max-w-[350px] group relative"
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"
>{item.size_mb
@ -172,46 +463,53 @@
: 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"
class="text-neon-green hover:underline flex items-center gap-1 transition-all hover:scale-105"
on:click={() =>
recoverFile(item.path)}
>Recover</button
><i class="bi bi-plus-circle"></i> RECOVER</button
>
{:else if activeTab === "redundant"}
<button
class="text-red-500 hover:underline"
class="text-red-500 hover:underline font-bold transition-all hover:scale-105 disabled:opacity-30"
on:click={() =>
deleteFile(item.path)}
>Delete</button
disabled={targetRO || (destructMode && sourceRO)}
><i class="bi bi-trash"></i> DELETE</button
>
{:else if activeTab === "lost"}
<button
class="text-neon-yellow hover:underline mr-2"
>Force</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"
class="text-red-500 hover:underline font-bold disabled:opacity-30"
on:click={() =>
deleteFile(item.path)}
>Delete</button
disabled={targetRO || (destructMode && sourceRO)}
><i class="bi bi-trash"></i> DELETE</button
>
{:else}
<button
class="text-neon-pink hover:underline"
>Rescue</button
class="text-neon-pink hover:underline flex items-center gap-1"
><i class="bi bi-rescue-ambulance"></i> RESCUE</button
>
{/if}
</div>
</td>
</tr>
{/each}
{#if !results[activeTab]?.length}
<tr
><td
colspan="4"
class="p-10 text-center text-gray-600"
>No items found.</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
></tr
>
{/if}
@ -221,3 +519,28 @@
</div>
</div>
</div>
<style>
.accent-neon-cyan {
accent-color: #00f3ff;
}
.bg-cyber-card {
background: linear-gradient(135deg, #0f0f13 0%, #050505 100%);
}
/* Custom Scrollbar for better Cyberpunk feel */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(15, 15, 15, 0.5);
}
::-webkit-scrollbar-thumb {
background: #00f3ff22;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #00f3ff66;
}
</style>

View file

@ -70,6 +70,12 @@ const { title } = Astro.props;
>
<i class="bi bi-film mr-1"></i> Transcoding
</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>
@ -95,5 +101,16 @@ 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>

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

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