diff --git a/pytorrent/db.py b/pytorrent/db.py index b89b2ea..30619b7 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -55,33 +55,39 @@ CREATE TABLE IF NOT EXISTS user_preferences ( bootstrap_theme TEXT DEFAULT 'default', font_family TEXT DEFAULT 'default', active_rtorrent_id INTEGER, - table_columns_json TEXT, keyboard_json TEXT, mobile_mode INTEGER DEFAULT 0, compact_torrent_list_enabled INTEGER DEFAULT 0, - peers_refresh_seconds INTEGER DEFAULT 0, - port_check_enabled INTEGER DEFAULT 0, footer_items_json TEXT, title_speed_enabled INTEGER DEFAULT 0, - tracker_favicons_enabled INTEGER DEFAULT 0, - reverse_dns_enabled INTEGER DEFAULT 0, automation_toasts_enabled INTEGER DEFAULT 1, smart_queue_toasts_enabled INTEGER DEFAULT 1, - disk_monitor_paths_json TEXT, - disk_monitor_mode TEXT DEFAULT 'default', - disk_monitor_selected_path TEXT, - disk_monitor_stop_enabled INTEGER DEFAULT 0, - disk_monitor_stop_threshold INTEGER DEFAULT 98, interface_scale INTEGER DEFAULT 100, detail_panel_height INTEGER DEFAULT 255, - torrent_sort_json TEXT, - active_filter TEXT DEFAULT 'all', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id); +CREATE TABLE IF NOT EXISTS profile_preferences ( + user_id INTEGER NOT NULL, + profile_id INTEGER NOT NULL, + table_columns_json TEXT, + torrent_sort_json TEXT, + active_filter TEXT DEFAULT 'all', + peers_refresh_seconds INTEGER DEFAULT 0, + port_check_enabled INTEGER DEFAULT 0, + tracker_favicons_enabled INTEGER DEFAULT 0, + reverse_dns_enabled INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(user_id, profile_id), + FOREIGN KEY(user_id) REFERENCES users(id), + FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) +); +CREATE INDEX IF NOT EXISTS idx_profile_preferences_user_profile ON profile_preferences(user_id, profile_id); + CREATE TABLE IF NOT EXISTS rtorrent_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -175,8 +181,7 @@ CREATE TABLE IF NOT EXISTS ratio_groups ( CREATE TABLE IF NOT EXISTS rss_feeds ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - profile_id INTEGER, + profile_id INTEGER NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER DEFAULT 1, @@ -190,8 +195,7 @@ CREATE TABLE IF NOT EXISTS rss_feeds ( CREATE TABLE IF NOT EXISTS rss_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - profile_id INTEGER, + profile_id INTEGER NOT NULL, name TEXT NOT NULL, pattern TEXT NOT NULL, exclude_pattern TEXT, @@ -208,13 +212,12 @@ CREATE TABLE IF NOT EXISTS rss_rules ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at); -CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled); +CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at); +CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled); CREATE TABLE IF NOT EXISTS rss_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - profile_id INTEGER, + profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, @@ -224,8 +227,7 @@ CREATE TABLE IF NOT EXISTS rss_history ( created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at); -CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at); -CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status); +CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status); CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added'); CREATE TABLE IF NOT EXISTS ratio_assignments ( @@ -262,12 +264,13 @@ CREATE TABLE IF NOT EXISTS app_backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, + backup_type TEXT DEFAULT 'app', + profile_id INTEGER, payload_json TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS smart_queue_settings ( - user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, @@ -288,7 +291,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings ( protect_active_below_cap INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, - PRIMARY KEY(user_id, profile_id) + PRIMARY KEY(profile_id) ); CREATE TABLE IF NOT EXISTS smart_queue_stalled ( @@ -309,19 +312,17 @@ CREATE TABLE IF NOT EXISTS smart_queue_start_grace ( ); CREATE TABLE IF NOT EXISTS smart_queue_exclusions ( - user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, - PRIMARY KEY(user_id, profile_id, torrent_hash) + PRIMARY KEY(profile_id, torrent_hash) ); -CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at); +CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at); CREATE TABLE IF NOT EXISTS smart_queue_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, event TEXT NOT NULL, paused_count INTEGER DEFAULT 0, @@ -332,7 +333,7 @@ CREATE TABLE IF NOT EXISTS smart_queue_history ( ); CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at); -CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at); + CREATE TABLE IF NOT EXISTS smart_queue_auto_labels ( profile_id INTEGER NOT NULL, @@ -410,14 +411,13 @@ CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_ CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at); CREATE TABLE IF NOT EXISTS rtorrent_config_overrides ( - user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, baseline_value TEXT, apply_on_start INTEGER DEFAULT 0, updated_at TEXT NOT NULL, - PRIMARY KEY(user_id, profile_id, key) + PRIMARY KEY(profile_id, key) ); CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start); @@ -426,6 +426,13 @@ CREATE TABLE IF NOT EXISTS app_settings ( value TEXT ); +CREATE TABLE IF NOT EXISTS poller_settings ( + profile_id INTEGER PRIMARY KEY, + settings_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) +); + CREATE TABLE IF NOT EXISTS download_plan_settings ( user_id INTEGER NOT NULL, @@ -514,18 +521,12 @@ MIGRATIONS = [ "ALTER TABLE users ADD COLUMN updated_at TEXT", "ALTER TABLE user_preferences ADD COLUMN mobile_mode INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN compact_torrent_list_enabled INTEGER DEFAULT 0", - "ALTER TABLE user_preferences ADD COLUMN peers_refresh_seconds INTEGER DEFAULT 0", - "ALTER TABLE user_preferences ADD COLUMN port_check_enabled INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT", "ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0", - "ALTER TABLE user_preferences ADD COLUMN tracker_favicons_enabled INTEGER DEFAULT 0", - "ALTER TABLE user_preferences ADD COLUMN reverse_dns_enabled INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100", "ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255", - "ALTER TABLE user_preferences ADD COLUMN torrent_sort_json TEXT", - "ALTER TABLE user_preferences ADD COLUMN active_filter TEXT DEFAULT 'all'", "ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5", "ALTER TABLE rtorrent_profiles ADD COLUMN light_parallel_jobs INTEGER DEFAULT 4", "ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300", @@ -560,11 +561,6 @@ MIGRATIONS = [ "CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)", "ALTER TABLE user_preferences ADD COLUMN automation_toasts_enabled INTEGER DEFAULT 1", "ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1", - "ALTER TABLE user_preferences ADD COLUMN disk_monitor_paths_json TEXT", - "ALTER TABLE user_preferences ADD COLUMN disk_monitor_mode TEXT DEFAULT 'default'", - "ALTER TABLE user_preferences ADD COLUMN disk_monitor_selected_path TEXT", - "ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_enabled INTEGER DEFAULT 0", - "ALTER TABLE user_preferences ADD COLUMN disk_monitor_stop_threshold INTEGER DEFAULT 98", "ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10", "ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT", "ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1", @@ -594,7 +590,7 @@ MIGRATIONS = [ "ALTER TABLE automation_history ADD COLUMN rule_name TEXT", "ALTER TABLE automation_history ADD COLUMN actions_json TEXT", "ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT", - "CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')", "CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))", @@ -607,15 +603,15 @@ MIGRATIONS = [ "CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at)", - "CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)", - "CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)", - "CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)", - "CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)", + "CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)", + "CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)", + "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", + "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status)", "CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id)", - "CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)", - "CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)", + "CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)", + "CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)", @@ -624,6 +620,10 @@ MIGRATIONS = [ "CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)", + "CREATE TABLE IF NOT EXISTS profile_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, table_columns_json TEXT, torrent_sort_json TEXT, active_filter TEXT DEFAULT 'all', peers_refresh_seconds INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0, tracker_favicons_enabled INTEGER DEFAULT 0, reverse_dns_enabled INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))", + "ALTER TABLE app_backups ADD COLUMN backup_type TEXT DEFAULT 'app'", + 'ALTER TABLE app_backups ADD COLUMN profile_id INTEGER', + 'CREATE TABLE IF NOT EXISTS poller_settings (profile_id INTEGER PRIMARY KEY, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))', "CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))", ] @@ -639,6 +639,81 @@ POST_MIGRATION_INDEXES = [ "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", ] + +PROFILE_ONLY_TABLES = { + "rss_feeds": { + "columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER DEFAULT 1, interval_minutes INTEGER DEFAULT 30, last_error TEXT, last_checked_at TEXT, next_check_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL", + "copy": ["id", "profile_id", "name", "url", "enabled", "interval_minutes", "last_error", "last_checked_at", "next_check_at", "created_at", "updated_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_feeds_profile_enabled_next ON rss_feeds(profile_id, enabled, next_check_at)"], + }, + "rss_rules": { + "columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, name TEXT NOT NULL, pattern TEXT NOT NULL, exclude_pattern TEXT, min_size_mb INTEGER DEFAULT 0, max_size_mb INTEGER DEFAULT 0, category TEXT, quality TEXT, season INTEGER, episode INTEGER, save_path TEXT, label TEXT, start INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL", + "copy": ["id", "profile_id", "name", "pattern", "exclude_pattern", "min_size_mb", "max_size_mb", "category", "quality", "season", "episode", "save_path", "label", "start", "enabled", "created_at", "updated_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_rules_profile_enabled ON rss_rules(profile_id, enabled)"], + }, + "rss_history": { + "columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL", + "copy": ["id", "profile_id", "feed_id", "rule_id", "title", "link", "status", "message", "created_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')"], + }, + "smart_queue_settings": { + "columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)", + "copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "auto_stop_idle", "updated_at"], + "indexes": [], + }, + "smart_queue_exclusions": { + "columns": "profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash)", + "copy": ["profile_id", "torrent_hash", "reason", "created_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_profile_created ON smart_queue_exclusions(profile_id, created_at)"], + }, + "smart_queue_history": { + "columns": "id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, event TEXT NOT NULL, paused_count INTEGER DEFAULT 0, resumed_count INTEGER DEFAULT 0, checked_count INTEGER DEFAULT 0, details_json TEXT, created_at TEXT NOT NULL", + "copy": ["id", "profile_id", "event", "paused_count", "resumed_count", "checked_count", "details_json", "created_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at)"], + }, + "rtorrent_config_overrides": { + "columns": "profile_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, baseline_value TEXT, apply_on_start INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, key)", + "copy": ["profile_id", "key", "value", "baseline_value", "apply_on_start", "updated_at"], + "indexes": ["CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start)"], + }, +} + + +def _table_columns(conn, table: str) -> set[str]: + try: + return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + except sqlite3.OperationalError: + return set() + + +def _normalize_profile_only_tables(conn) -> None: + """Move operational settings from user scope to profile scope on existing databases.""" + for table, spec in PROFILE_ONLY_TABLES.items(): + columns = _table_columns(conn, table) + if not columns or "user_id" not in columns: + for index_sql in spec["indexes"]: + try: + conn.execute(index_sql) + except sqlite3.OperationalError: + pass + continue + tmp = f"{table}_profile_scope_tmp" + conn.execute("PRAGMA foreign_keys = OFF") + conn.execute(f"DROP TABLE IF EXISTS {tmp}") + conn.execute(f"CREATE TABLE {tmp} ({spec['columns']})") + copy_cols = [col for col in spec["copy"] if col in columns] + if copy_cols: + col_sql = ",".join(copy_cols) + if table in {"smart_queue_settings", "smart_queue_exclusions", "rtorrent_config_overrides"}: + conn.execute(f"INSERT OR REPLACE INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL") + else: + conn.execute(f"INSERT INTO {tmp}({col_sql}) SELECT {col_sql} FROM {table} WHERE profile_id IS NOT NULL") + conn.execute(f"DROP TABLE {table}") + conn.execute(f"ALTER TABLE {tmp} RENAME TO {table}") + for index_sql in spec["indexes"]: + conn.execute(index_sql) + conn.execute("PRAGMA foreign_keys = ON") + def utcnow() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") @@ -679,6 +754,7 @@ def init_db(): conn.execute(sql) except sqlite3.OperationalError: pass + _normalize_profile_only_tables(conn) now = utcnow() conn.execute( "INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)", diff --git a/pytorrent/routes/backup.py b/pytorrent/routes/backup.py index c0807b9..37113c4 100644 --- a/pytorrent/routes/backup.py +++ b/pytorrent/routes/backup.py @@ -1,31 +1,96 @@ from __future__ import annotations from ._shared import * +from ..services import auth + + +def _active_profile_id() -> int | None: + profile = preferences.active_profile() + return int(profile["id"]) if profile else None + @bp.get("/backup") def backup_list(): - return ok({"backups": backup_service.list_backups(default_user_id()), "auto": backup_service.get_auto_backup_settings(default_user_id())}) + uid = default_user_id() + pid = _active_profile_id() + can_app = auth.is_admin() + return ok({ + "profile_backups": backup_service.list_backups(uid, "profile", pid) if pid else [], + "app_backups": backup_service.list_backups(uid, "app") if can_app else [], + "profile_auto": backup_service.get_auto_backup_settings(uid, "profile", pid) if pid else None, + "app_auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None, + "auto": backup_service.get_auto_backup_settings(uid, "app") if can_app else None, + "can_app_backup": can_app, + }) +@bp.post("/backup/profile") +def backup_create_profile(): + data = request.get_json(silent=True) or {} + pid = _active_profile_id() + if not pid: + return jsonify({"ok": False, "error": "No profile"}), 400 + try: + return ok({ + "backup": backup_service.create_profile_backup(str(data.get("name") or "Profile backup"), pid, default_user_id()), + "profile_backups": backup_service.list_backups(default_user_id(), "profile", pid), + }) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@bp.post("/backup/app") +def backup_create_app(): + data = request.get_json(silent=True) or {} + try: + return ok({ + "backup": backup_service.create_app_backup(str(data.get("name") or "Application backup"), default_user_id()), + "app_backups": backup_service.list_backups(default_user_id(), "app"), + }) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 + @bp.post("/backup") def backup_create(): - data = request.get_json(silent=True) or {} - return ok({"backup": backup_service.create_backup(str(data.get("name") or "Manual backup"), default_user_id()), "backups": backup_service.list_backups(default_user_id())}) + # Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings. + return backup_create_profile() @bp.get("/backup/settings") def backup_settings_get(): - return ok({"settings": backup_service.get_auto_backup_settings(default_user_id())}) + if not auth.is_admin(): + return jsonify({"ok": False, "error": "Application backup settings are admin-only"}), 403 + return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "app")}) @bp.post("/backup/settings") def backup_settings_save(): data = request.get_json(silent=True) or {} try: - return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id())}) + return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "app")}) except Exception as exc: - return jsonify({"ok": False, "error": str(exc)}), 400 + return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 + + +@bp.get("/backup/profile/settings") +def profile_backup_settings_get(): + pid = _active_profile_id() + if not pid: + return jsonify({"ok": False, "error": "No profile"}), 400 + return ok({"settings": backup_service.get_auto_backup_settings(default_user_id(), "profile", pid)}) + + +@bp.post("/backup/profile/settings") +def profile_backup_settings_save(): + data = request.get_json(silent=True) or {} + pid = _active_profile_id() + if not pid: + return jsonify({"ok": False, "error": "No profile"}), 400 + try: + return ok({"settings": backup_service.save_auto_backup_settings(data, default_user_id(), "profile", pid)}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 @bp.get("/backup//preview") @@ -36,14 +101,13 @@ def backup_preview(backup_id: int): return jsonify({"ok": False, "error": str(exc)}), 400 - @bp.post("/backup//restore") def backup_restore(backup_id: int): try: - return ok({"result": backup_service.restore_backup(backup_id, default_user_id())}) + pid = _active_profile_id() + return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)}) except Exception as exc: - return jsonify({"ok": False, "error": str(exc)}), 400 - + return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 @bp.delete("/backup/") @@ -54,7 +118,6 @@ def backup_delete(backup_id: int): return jsonify({"ok": False, "error": str(exc)}), 400 - @bp.get("/backup//download") def backup_download(backup_id: int): try: @@ -62,8 +125,6 @@ def backup_download(backup_id: int): tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-backup-", suffix=".json", delete=False, mode="w", encoding="utf-8") json.dump(payload, tmp, ensure_ascii=False, indent=2) tmp.close() - return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-backup-{backup_id}.json") + return send_file(tmp.name, as_attachment=True, download_name=f"pytorrent-{payload.get('backup_type') or 'backup'}-{backup_id}.json") except Exception as exc: return jsonify({"ok": False, "error": str(exc)}), 400 - - diff --git a/pytorrent/routes/rss.py b/pytorrent/routes/rss.py index 494077c..7bf984c 100644 --- a/pytorrent/routes/rss.py +++ b/pytorrent/routes/rss.py @@ -2,65 +2,109 @@ from __future__ import annotations from ._shared import * + +def _active_profile_or_400(): + profile = preferences.active_profile() + if not profile: + return None + return profile + + @bp.get("/rss") def rss_list(): - profile = preferences.active_profile() - pid = profile["id"] if profile else None + profile = _active_profile_or_400() + if not profile: + return ok({"feeds": [], "rules": [], "history": []}) + pid = int(profile["id"]) with connect() as conn: - feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() - rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY name", (default_user_id(), pid)).fetchall() - history = conn.execute("SELECT * FROM rss_history WHERE user_id=? AND (profile_id=? OR profile_id IS NULL) ORDER BY id DESC LIMIT 80", (default_user_id(), pid)).fetchall() + feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? ORDER BY name", (pid,)).fetchall() + rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? ORDER BY name", (pid,)).fetchall() + history = conn.execute("SELECT * FROM rss_history WHERE profile_id=? ORDER BY id DESC LIMIT 80", (pid,)).fetchall() return ok({"feeds": feeds, "rules": rules, "history": history}) - @bp.post("/rss/feeds") def rss_feed_save(): - profile = preferences.active_profile() + profile = _active_profile_or_400() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} now = utcnow() feed_id = data.get("id") + pid = int(profile["id"]) with connect() as conn: if feed_id: - conn.execute("UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND user_id=?", (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, default_user_id())) + conn.execute( + "UPDATE rss_feeds SET name=?,url=?,enabled=?,interval_minutes=?,updated_at=? WHERE id=? AND profile_id=?", + (data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, feed_id, pid), + ) else: - conn.execute("INSERT INTO rss_feeds(user_id,profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now)) + conn.execute( + "INSERT INTO rss_feeds(profile_id,name,url,enabled,interval_minutes,created_at,updated_at) VALUES(?,?,?,?,?,?,?)", + (pid, data.get("name") or "RSS", data.get("url") or "", 1 if data.get("enabled", True) else 0, int(data.get("interval_minutes") or 30), now, now), + ) return rss_list() - @bp.delete("/rss/feeds/") def rss_feed_delete(feed_id: int): + profile = _active_profile_or_400() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 with connect() as conn: - conn.execute("DELETE FROM rss_feeds WHERE id=? AND user_id=?", (feed_id, default_user_id())) + conn.execute("DELETE FROM rss_feeds WHERE id=? AND profile_id=?", (feed_id, int(profile["id"]))) return rss_list() - @bp.post("/rss/rules") def rss_rule_save(): - profile = preferences.active_profile() + profile = _active_profile_or_400() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} now = utcnow() rule_id = data.get("id") - values = (data.get("name") or "Rule", data.get("pattern") or ".*", data.get("exclude_pattern") or "", int(data.get("min_size_mb") or 0), int(data.get("max_size_mb") or 0), data.get("category") or "", data.get("quality") or "", data.get("season") or None, data.get("episode") or None, data.get("save_path") or active_default_download_path(profile), data.get("label") or "", 1 if data.get("start", True) else 0, 1 if data.get("enabled", True) else 0, now) + pid = int(profile["id"]) + values = ( + data.get("name") or "Rule", + data.get("pattern") or ".*", + data.get("exclude_pattern") or "", + int(data.get("min_size_mb") or 0), + int(data.get("max_size_mb") or 0), + data.get("category") or "", + data.get("quality") or "", + data.get("season") or None, + data.get("episode") or None, + data.get("save_path") or active_default_download_path(profile), + data.get("label") or "", + 1 if data.get("start", True) else 0, + 1 if data.get("enabled", True) else 0, + now, + ) with connect() as conn: if rule_id: - conn.execute("UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND user_id=?", (*values, rule_id, default_user_id())) + conn.execute( + "UPDATE rss_rules SET name=?,pattern=?,exclude_pattern=?,min_size_mb=?,max_size_mb=?,category=?,quality=?,season=?,episode=?,save_path=?,label=?,start=?,enabled=?,updated_at=? WHERE id=? AND profile_id=?", + (*values, rule_id, pid), + ) else: - conn.execute("INSERT INTO rss_rules(user_id,profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (default_user_id(), profile["id"] if profile else None, *values, now)) + conn.execute( + "INSERT INTO rss_rules(profile_id,name,pattern,exclude_pattern,min_size_mb,max_size_mb,category,quality,season,episode,save_path,label,start,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (pid, *values, now), + ) return rss_list() - @bp.delete("/rss/rules/") def rss_rule_delete(rule_id: int): + profile = _active_profile_or_400() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 with connect() as conn: - conn.execute("DELETE FROM rss_rules WHERE id=? AND user_id=?", (rule_id, default_user_id())) + conn.execute("DELETE FROM rss_rules WHERE id=? AND profile_id=?", (rule_id, int(profile["id"]))) return rss_list() - @bp.post("/rss/rules/test") def rss_rule_test(): data = request.get_json(silent=True) or {} @@ -71,12 +115,9 @@ def rss_rule_test(): return jsonify({"ok": False, "error": str(exc)}), 400 - @bp.post("/rss/check") def rss_check(): profile = preferences.active_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 - return ok(rss_service.check(profile, default_user_id(), only_due=False)) - - + return ok(rss_service.check(profile, only_due=False)) diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index e90e0bc..3af6b65 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -198,12 +198,17 @@ def cleanup_jobs(): @bp.post("/cleanup/smart-queue") def cleanup_smart_queue(): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + profile_id = int(profile["id"]) with connect() as conn: exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone() if not exists: deleted = 0 else: - cur = conn.execute("DELETE FROM smart_queue_history") + # Note: Cleanup is limited to the active profile so read/write permissions never affect other profiles. + cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (profile_id,)) deleted = int(cur.rowcount or 0) return ok({"deleted": deleted, "cleanup": cleanup_summary()}) @@ -232,13 +237,17 @@ def cleanup_planner(): @bp.post("/cleanup/automations") def cleanup_automations(): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + profile_id = int(profile["id"]) with connect() as conn: exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() if not exists: deleted = 0 else: - # Note: Cleanup panel removes only automation logs, not saved automation rules. - cur = conn.execute("DELETE FROM automation_history") + # Note: Cleanup panel removes only active profile automation logs, not saved automation rules. + cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (profile_id,)) deleted = int(cur.rowcount or 0) return ok({"deleted": deleted, "cleanup": cleanup_summary()}) @@ -256,13 +265,13 @@ def cleanup_all(): if not exists: deleted_smart = 0 else: - cur = conn.execute("DELETE FROM smart_queue_history") + cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (active_profile_id,)) deleted_smart = int(cur.rowcount or 0) exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() if not exists_auto: deleted_auto = 0 else: - cur = conn.execute("DELETE FROM automation_history") + cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (active_profile_id,)) deleted_auto = int(cur.rowcount or 0) return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()}) diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py index b2921d5..b04ed65 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -31,11 +31,16 @@ RTORRENT_WRITE_PREFIXES = ( "/api/rss", "/api/smart-queue", "/api/automations", + "/api/download-planner", + "/api/poller/settings", + "/api/operation-logs", "/api/jobs", + "/api/cleanup", ) RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",) ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles") # Note: API reads that expose rTorrent/profile data must also respect profile permissions. +# Note: Planner, poller and operation-log endpoints are profile-scoped and must follow the active profile context. PROFILE_READ_PREFIXES = ( "/api/torrents", "/api/torrent-stats", @@ -50,6 +55,9 @@ PROFILE_READ_PREFIXES = ( "/api/smart-queue", "/api/traffic/history", "/api/automations", + "/api/download-planner", + "/api/poller/settings", + "/api/operation-logs", ) diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index cbb4e4a..ad4739d 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -5,15 +5,39 @@ import threading import time from datetime import datetime, timedelta, timezone from ..db import connect, utcnow, default_user_id +from . import auth -# Note: Settings backups include persistent configuration tables only; volatile queues, caches, histories and tokens are intentionally skipped. -BACKUP_TABLES = [ - "users", "user_profile_permissions", "user_preferences", "rtorrent_profiles", +# Note: Application backups are admin-only because they include users, permissions and all profiles. +APP_BACKUP_TABLES = [ + "users", "user_profile_permissions", "user_preferences", "profile_preferences", "rtorrent_profiles", "disk_monitor_preferences", "labels", "ratio_groups", "rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions", "automation_rules", - "rtorrent_config_overrides", "app_settings", "download_plan_settings", + "rtorrent_config_overrides", "poller_settings", "app_settings", "download_plan_settings", ] +# Note: Profile backups contain active profile data. User-specific preferences remain scoped to the current user. +PROFILE_BACKUP_TABLES = [ + "rtorrent_profiles", "profile_preferences", "disk_monitor_preferences", "labels", "ratio_groups", + "rss_feeds", "rss_rules", "smart_queue_settings", "smart_queue_exclusions", + "automation_rules", "rtorrent_config_overrides", "poller_settings", "download_plan_settings", +] + +PROFILE_TABLE_FILTERS = { + "rtorrent_profiles": "id=?", + "profile_preferences": "user_id=? AND profile_id=?", + "disk_monitor_preferences": "user_id=? AND profile_id=?", + "labels": "user_id=? AND profile_id=?", + "ratio_groups": "user_id=? AND profile_id=?", + "rss_feeds": "profile_id=?", + "rss_rules": "profile_id=?", + "smart_queue_settings": "profile_id=?", + "smart_queue_exclusions": "profile_id=?", + "automation_rules": "user_id=? AND profile_id=?", + "rtorrent_config_overrides": "profile_id=?", + "poller_settings": "profile_id=?", + "download_plan_settings": "user_id=? AND profile_id=?", +} + DEFAULT_AUTO_BACKUP_SETTINGS = { "enabled": False, "interval_hours": 24, @@ -22,101 +46,26 @@ DEFAULT_AUTO_BACKUP_SETTINGS = { } BACKUP_PREVIEW_VALUE_LIMIT = 80 BACKUP_PREVIEW_ROW_LIMIT = 3 -BACKUP_PREVIEW_SENSITIVE_KEYS = { - "password", - "password_hash", - "token", - "token_hash", - "api_key", - "secret", -} +BACKUP_PREVIEW_SENSITIVE_KEYS = {"password", "password_hash", "token", "token_hash", "api_key", "secret"} AUTO_BACKUP_SETTINGS_KEY = "backup:auto" _scheduler_started = False _scheduler_lock = threading.Lock() -def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict: - """Create a settings backup and return a table-count summary. - - Note: The automatic flag is metadata only; restore/download behavior remains unchanged. - """ - user_id = user_id or default_user_id() - payload = {"version": 1, "created_at": utcnow(), "automatic": bool(automatic), "tables": {}} +def _is_admin_user(user_id: int | None = None) -> bool: + if not auth.enabled(): + return True + uid = user_id or auth.current_user_id() + if not uid: + return False with connect() as conn: - for table in BACKUP_TABLES: - try: - payload["tables"][table] = conn.execute(f"SELECT * FROM {table}").fetchall() - except Exception: - payload["tables"][table] = [] - cur = conn.execute( - "INSERT INTO app_backups(user_id,name,payload_json,created_at) VALUES(?,?,?,?)", - (user_id, name or f"Backup {payload['created_at']}", json.dumps(payload), payload["created_at"]), - ) - backup_id = cur.lastrowid - return {"id": backup_id, "name": name, "created_at": payload["created_at"], "automatic": bool(automatic), "tables": {k: len(v) for k, v in payload["tables"].items()}} - - -def list_backups(user_id: int | None = None) -> list[dict]: - user_id = user_id or default_user_id() - with connect() as conn: - rows = conn.execute("SELECT id,name,created_at,payload_json FROM app_backups WHERE user_id=? ORDER BY id DESC", (user_id,)).fetchall() - result = [] - for row in rows: - payload = _loads(row.get("payload_json") or "{}") - tables = payload.get("tables") or {} - result.append({ - "id": row.get("id"), - "name": row.get("name"), - "created_at": row.get("created_at"), - "automatic": bool(payload.get("automatic")), - "tables": {key: len(value or []) for key, value in tables.items()}, - }) - return result - - -def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict: - user_id = user_id or default_user_id() - with connect() as conn: - row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone() - if not row: - raise ValueError("Backup not found") - return json.loads(row["payload_json"] or "{}") - - -def restore_backup(backup_id: int, user_id: int | None = None) -> dict: - user_id = user_id or default_user_id() - payload = payload_for_backup(backup_id, user_id) - tables = payload.get("tables") or {} - restored = {} - with connect() as conn: - conn.execute("PRAGMA foreign_keys = OFF") - try: - for table in BACKUP_TABLES: - rows = tables.get(table) or [] - if not rows: - continue - columns = list(rows[0].keys()) - placeholders = ",".join("?" for _ in columns) - conn.execute(f"DELETE FROM {table}") - for row in rows: - conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns]) - restored[table] = len(rows) - finally: - conn.execute("PRAGMA foreign_keys = ON") - return {"restored": restored} - -def delete_backup(backup_id: int, user_id: int | None = None) -> dict: - user_id = user_id or default_user_id() - with connect() as conn: - cur = conn.execute( - "DELETE FROM app_backups WHERE id=? AND user_id=?", - (backup_id, user_id), - ) - if not cur.rowcount: - raise ValueError("Backup not found") - return {"deleted": backup_id} + row = conn.execute("SELECT role,is_active FROM users WHERE id=?", (uid,)).fetchone() + return bool(row and row.get("role") == "admin" and int(row.get("is_active") or 0)) +def _require_admin(user_id: int | None = None) -> None: + if not _is_admin_user(user_id): + raise PermissionError("Application backups are available only to admins") def _loads(value: str) -> dict: @@ -127,26 +76,240 @@ def _loads(value: str) -> dict: return {} -def _settings_row_key(user_id: int | None = None) -> str: - return f"{AUTO_BACKUP_SETTINGS_KEY}:{user_id or default_user_id()}" +def _table_columns(conn, table: str) -> set[str]: + try: + return {str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + except Exception: + return set() -def _latest_backup_created_at(user_id: int) -> str | None: - """Return the newest persisted backup timestamp for scheduler recovery after restarts. +def _table_rows(conn, table: str, where: str | None = None, params: tuple = ()) -> list[dict]: + try: + sql = f"SELECT * FROM {table}" + (f" WHERE {where}" if where else "") + return [dict(row) for row in conn.execute(sql, params).fetchall()] + except Exception: + return [] - Note: Automatic scheduling is based on the latest database backup record, so process - restarts cannot create repeated backups before the configured interval elapses. - """ + +def _store_backup(user_id: int, name: str, backup_type: str, profile_id: int | None, payload: dict) -> dict: + with connect() as conn: + cur = conn.execute( + "INSERT INTO app_backups(user_id,name,backup_type,profile_id,payload_json,created_at) VALUES(?,?,?,?,?,?)", + (user_id, name or f"Backup {payload['created_at']}", backup_type, profile_id, json.dumps(payload), payload["created_at"]), + ) + backup_id = cur.lastrowid + return { + "id": backup_id, + "name": name, + "backup_type": backup_type, + "profile_id": profile_id, + "created_at": payload["created_at"], + "automatic": bool(payload.get("automatic")), + "tables": {k: len(v) for k, v in (payload.get("tables") or {}).items()}, + } + + +def create_app_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict: + user_id = user_id or auth.current_user_id() or default_user_id() + _require_admin(user_id) + payload = {"version": 2, "backup_type": "app", "created_at": utcnow(), "automatic": bool(automatic), "tables": {}} + with connect() as conn: + for table in APP_BACKUP_TABLES: + payload["tables"][table] = _table_rows(conn, table) + return _store_backup(user_id, name, "app", None, payload) + + +def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict: + user_id = user_id or auth.current_user_id() or default_user_id() + if not auth.can_access_profile(profile_id, user_id): + raise PermissionError("No access to profile") + payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}} + with connect() as conn: + for table in PROFILE_BACKUP_TABLES: + where = PROFILE_TABLE_FILTERS.get(table) + if where == "id=?" or where == "profile_id=?": + params = (int(profile_id),) + else: + params = (user_id, int(profile_id)) + payload["tables"][table] = _table_rows(conn, table, where, params) + return _store_backup(user_id, name, "profile", int(profile_id), payload) + + +def create_backup(name: str, user_id: int | None = None, automatic: bool = False) -> dict: + return create_app_backup(name, user_id, automatic) + + +def list_backups(user_id: int | None = None, backup_type: str | None = None, profile_id: int | None = None) -> list[dict]: + user_id = user_id or auth.current_user_id() or default_user_id() + clauses = ["user_id=?"] + params: list[object] = [user_id] + if backup_type: + clauses.append("COALESCE(backup_type,'app')=?") + params.append(backup_type) + if profile_id is not None: + clauses.append("profile_id=?") + params.append(int(profile_id)) + with connect() as conn: + rows = conn.execute( + f"SELECT id,name,created_at,payload_json,COALESCE(backup_type,'app') AS backup_type,profile_id FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY id DESC", + tuple(params), + ).fetchall() + result = [] + for row in rows: + payload = _loads(row.get("payload_json") or "{}") + tables = payload.get("tables") or {} + result.append({ + "id": row.get("id"), + "name": row.get("name"), + "created_at": row.get("created_at"), + "backup_type": row.get("backup_type") or payload.get("backup_type") or "app", + "profile_id": row.get("profile_id") or payload.get("source_profile_id"), + "automatic": bool(payload.get("automatic")), + "tables": {key: len(value or []) for key, value in tables.items()}, + }) + return result + + +def payload_for_backup(backup_id: int, user_id: int | None = None) -> dict: + user_id = user_id or auth.current_user_id() or default_user_id() + with connect() as conn: + row = conn.execute("SELECT payload_json FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)).fetchone() + if not row: + raise ValueError("Backup not found") + return json.loads(row["payload_json"] or "{}") + + +def _backup_type(payload: dict) -> str: + return str(payload.get("backup_type") or ("profile" if payload.get("source_profile_id") else "app")) + + +def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict: + user_id = user_id or auth.current_user_id() or default_user_id() + _require_admin(user_id) + payload = payload_for_backup(backup_id, user_id) + if _backup_type(payload) != "app": + raise ValueError("This is not an application backup") + tables = payload.get("tables") or {} + restored = {} + with connect() as conn: + conn.execute("PRAGMA foreign_keys = OFF") + try: + for table in APP_BACKUP_TABLES: + rows = tables.get(table) or [] + if not rows: + continue + available = _table_columns(conn, table) + columns = [col for col in rows[0].keys() if col in available] + if not columns: + continue + placeholders = ",".join("?" for _ in columns) + conn.execute(f"DELETE FROM {table}") + for row in rows: + conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [row.get(col) for col in columns]) + restored[table] = len(rows) + finally: + conn.execute("PRAGMA foreign_keys = ON") + return {"restored": restored, "backup_type": "app"} + + +def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict: + clean = dict(row) + if table == "rtorrent_profiles": + clean["id"] = target_profile_id + clean["user_id"] = user_id + clean["is_default"] = int(clean.get("is_default") or 0) + return clean + if "profile_id" in clean: + clean["profile_id"] = target_profile_id + if "user_id" in clean: + clean["user_id"] = user_id + if table == "poller_settings": + clean["profile_id"] = target_profile_id + if "id" in clean and table != "rtorrent_profiles": + clean.pop("id", None) + return clean + + +def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int | None = None) -> dict: + user_id = user_id or auth.current_user_id() or default_user_id() + if not auth.can_write_profile(target_profile_id, user_id): + raise PermissionError("No write access to profile") + payload = payload_for_backup(backup_id, user_id) + if _backup_type(payload) != "profile": + raise ValueError("This is not a profile backup") + tables = payload.get("tables") or {} + restored = {} + with connect() as conn: + conn.execute("PRAGMA foreign_keys = OFF") + try: + for table in PROFILE_BACKUP_TABLES: + rows = tables.get(table) or [] + where = PROFILE_TABLE_FILTERS.get(table) + if where == "id=?" or where == "profile_id=?": + params = (int(target_profile_id),) + else: + params = (user_id, int(target_profile_id)) + conn.execute(f"DELETE FROM {table} WHERE {where}", params) + if not rows: + continue + count = 0 + for row in rows: + clean = _rewrite_profile_row(table, dict(row), user_id, int(target_profile_id)) + available = _table_columns(conn, table) + columns = [col for col in clean.keys() if col in available] + if not columns: + continue + placeholders = ",".join("?" for _ in columns) + conn.execute(f"INSERT INTO {table}({','.join(columns)}) VALUES({placeholders})", [clean.get(col) for col in columns]) + count += 1 + restored[table] = count + finally: + conn.execute("PRAGMA foreign_keys = ON") + return {"restored": restored, "backup_type": "profile", "profile_id": int(target_profile_id)} + + +def restore_backup(backup_id: int, user_id: int | None = None, profile_id: int | None = None) -> dict: + payload = payload_for_backup(backup_id, user_id) + if _backup_type(payload) == "profile": + target = profile_id or payload.get("source_profile_id") + if not target: + raise ValueError("Missing target profile") + return restore_profile_backup(backup_id, int(target), user_id) + return restore_app_backup(backup_id, user_id) + + +def delete_backup(backup_id: int, user_id: int | None = None) -> dict: + user_id = user_id or auth.current_user_id() or default_user_id() + with connect() as conn: + cur = conn.execute("DELETE FROM app_backups WHERE id=? AND user_id=?", (backup_id, user_id)) + if not cur.rowcount: + raise ValueError("Backup not found") + return {"deleted": backup_id} + + +def _settings_row_key(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> str: + uid = user_id or auth.current_user_id() or default_user_id() + scope = "profile" if backup_type == "profile" else "app" + if scope == "profile": + return f"{AUTO_BACKUP_SETTINGS_KEY}:profile:{uid}:{int(profile_id or 0)}" + return f"{AUTO_BACKUP_SETTINGS_KEY}:app:{uid}" + + +def _latest_backup_created_at(user_id: int, backup_type: str = "app", profile_id: int | None = None) -> str | None: + clauses = ["user_id=?", "COALESCE(backup_type,'app')=?"] + params: list[object] = [user_id, backup_type] + if backup_type == "profile": + clauses.append("profile_id=?") + params.append(int(profile_id or 0)) with connect() as conn: row = conn.execute( - "SELECT created_at FROM app_backups WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1", - (user_id,), + f"SELECT created_at FROM app_backups WHERE {' AND '.join(clauses)} ORDER BY created_at DESC, id DESC LIMIT 1", + tuple(params), ).fetchone() return str(row["created_at"] or "") if row and row.get("created_at") else None def _preview_value(value: object) -> object: - """Return a safe, compact value for backup previews without exposing secrets.""" if value is None or isinstance(value, (int, float, bool)): return value text = str(value) @@ -157,34 +320,34 @@ def _preview_row(row: dict) -> dict: output = {} for key, value in row.items(): lowered = str(key).lower() - if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS): - output[key] = "[hidden]" - else: - output[key] = _preview_value(value) + output[key] = "[hidden]" if any(secret in lowered for secret in BACKUP_PREVIEW_SENSITIVE_KEYS) else _preview_value(value) return output -def get_auto_backup_settings(user_id: int | None = None) -> dict: - """Return automatic backup schedule settings for the current user. - - Note: The UI uses this as the single source for interval and retention controls. - """ - key = _settings_row_key(user_id) +def get_auto_backup_settings(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict: + key = _settings_row_key(user_id, backup_type, profile_id) with connect() as conn: row = conn.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone() settings = {**DEFAULT_AUTO_BACKUP_SETTINGS, **_loads(row.get("value") if row else "{}")} settings["enabled"] = bool(settings.get("enabled")) settings["interval_hours"] = max(1, int(settings.get("interval_hours") or 24)) settings["retention_days"] = max(1, int(settings.get("retention_days") or 30)) + settings["backup_type"] = "profile" if backup_type == "profile" else "app" + if backup_type == "profile": + settings["profile_id"] = int(profile_id or 0) return settings -def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict: - """Persist automatic backup schedule settings after validating UI input. - - Note: Minimum interval is one hour to avoid creating excessive database rows. - """ - current = get_auto_backup_settings(user_id) +def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict: + user_id = user_id or auth.current_user_id() or default_user_id() + backup_type = "profile" if backup_type == "profile" else "app" + if backup_type == "app": + _require_admin(user_id) + else: + # Note: Profile backup schedules affect profile operations, so read-only users may view/export backups but cannot change automation. + if not profile_id or not auth.can_write_profile(int(profile_id), user_id): + raise PermissionError("No write access to profile") + current = get_auto_backup_settings(user_id, backup_type, profile_id) settings = { **current, "enabled": bool(data.get("enabled")), @@ -192,22 +355,20 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None) -> dict: "retention_days": max(1, int(data.get("retention_days") or current["retention_days"])), "last_run_at": data.get("last_run_at", current.get("last_run_at")), } - key = _settings_row_key(user_id) + key = _settings_row_key(user_id, backup_type, profile_id) with connect() as conn: conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (key, json.dumps(settings))) return settings def preview_backup(backup_id: int, user_id: int | None = None) -> dict: - """Return a compact backup preview without exposing the full JSON payload in the list view. - - Note: The preview shows included tables and example keys so users can verify settings coverage. - """ payload = payload_for_backup(backup_id, user_id) tables = payload.get("tables") or {} return { "version": payload.get("version"), "created_at": payload.get("created_at"), + "backup_type": _backup_type(payload), + "source_profile_id": payload.get("source_profile_id"), "automatic": bool(payload.get("automatic")), "tables": [ { @@ -221,50 +382,70 @@ def preview_backup(backup_id: int, user_id: int | None = None) -> dict: } -def prune_old_backups(user_id: int | None = None, retention_days: int = 30) -> int: - """Delete backups older than the configured retention window for the selected user. - - Note: Retention is applied only to backup records, not to restored application settings. - """ - user_id = user_id or default_user_id() +def prune_old_backups(user_id: int | None = None, retention_days: int = 30, backup_type: str = "app", profile_id: int | None = None) -> int: + user_id = user_id or auth.current_user_id() or default_user_id() cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, int(retention_days)))).isoformat(timespec="seconds") + clauses = ["user_id=?", "COALESCE(backup_type,'app')=?", "created_at dict | None: - """Create an automatic backup when the saved interval has elapsed. - - Note: The scheduler calls this periodically, while the UI controls the interval and retention values. - """ - user_id = user_id or default_user_id() - settings = get_auto_backup_settings(user_id) - if not settings.get("enabled"): - return None +def _should_run(settings: dict, last_value: str | None) -> bool: now = datetime.now(timezone.utc) - last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id) try: last = datetime.fromisoformat(str(last_value).replace("Z", "+00:00")) if last_value else None except Exception: last = None - if last and now - last < timedelta(hours=settings["interval_hours"]): + return not last or now - last >= timedelta(hours=settings["interval_hours"]) + + +def maybe_create_automatic_backup(user_id: int | None = None, backup_type: str = "app", profile_id: int | None = None) -> dict | None: + user_id = user_id or default_user_id() + backup_type = "profile" if backup_type == "profile" else "app" + if backup_type == "app" and not _is_admin_user(user_id): + return None + if backup_type == "profile" and (not profile_id or not auth.can_access_profile(int(profile_id), user_id)): + return None + settings = get_auto_backup_settings(user_id, backup_type, profile_id) + if not settings.get("enabled"): + return None + last_value = settings.get("last_run_at") or _latest_backup_created_at(user_id, backup_type, profile_id) + if not _should_run(settings, last_value): if settings.get("last_run_at") != last_value: settings["last_run_at"] = last_value - save_auto_backup_settings(settings, user_id) + save_auto_backup_settings(settings, user_id, backup_type, profile_id) return None - backup = create_backup(f"Automatic backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) + now = datetime.now(timezone.utc) + if backup_type == "profile": + backup = create_profile_backup(f"Automatic profile backup {now.isoformat(timespec='seconds')}", int(profile_id or 0), user_id, automatic=True) + else: + backup = create_app_backup(f"Automatic application backup {now.isoformat(timespec='seconds')}", user_id, automatic=True) settings["last_run_at"] = backup.get("created_at") or now.isoformat(timespec="seconds") - save_auto_backup_settings(settings, user_id) - prune_old_backups(user_id, settings["retention_days"]) + save_auto_backup_settings(settings, user_id, backup_type, profile_id) + prune_old_backups(user_id, settings["retention_days"], backup_type, profile_id) return backup -def start_scheduler() -> None: - """Start a lightweight automatic-backup scheduler. +def _profile_schedule_keys() -> list[tuple[int, int]]: + prefix = f"{AUTO_BACKUP_SETTINGS_KEY}:profile:" + keys: list[tuple[int, int]] = [] + with connect() as conn: + rows = conn.execute("SELECT key FROM app_settings WHERE key LIKE ?", (prefix + "%",)).fetchall() + for row in rows: + parts = str(row.get("key") or "").split(":") + try: + keys.append((int(parts[-2]), int(parts[-1]))) + except Exception: + continue + return keys - Note: It scans configured users and never blocks normal request handling. - """ + +def start_scheduler() -> None: global _scheduler_started with _scheduler_lock: if _scheduler_started: @@ -275,10 +456,12 @@ def start_scheduler() -> None: while True: try: with connect() as conn: - rows = conn.execute("SELECT id FROM users WHERE is_active=1").fetchall() + rows = conn.execute("SELECT id FROM users WHERE is_active=1 AND role='admin'").fetchall() user_ids = [int(row["id"]) for row in rows] or [default_user_id()] for uid in user_ids: - maybe_create_automatic_backup(uid) + maybe_create_automatic_backup(uid, "app") + for uid, pid in _profile_schedule_keys(): + maybe_create_automatic_backup(uid, "profile", pid) except Exception: pass time.sleep(300) diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py index 778721a..93638ab 100644 --- a/pytorrent/services/frontend_assets.py +++ b/pytorrent/services/frontend_assets.py @@ -55,6 +55,10 @@ PYTORRENT_APP_THEMES = { "nord": "pyTorrent Nord", "crimson": "pyTorrent Crimson", "sky": "pyTorrent Sky", + "bootstrap22": "Bootstrap 2 Classic", + "bootstrap22-inverse": "Bootstrap 2 Inverse", + "bootstrap3": "Bootstrap 3 Glyph", + "bootstrap3-inverse": "Bootstrap 3 Inverse", } diff --git a/pytorrent/services/poller_control.py b/pytorrent/services/poller_control.py index 8df4b42..8735346 100644 --- a/pytorrent/services/poller_control.py +++ b/pytorrent/services/poller_control.py @@ -73,9 +73,19 @@ def normalize_settings(data: dict | None) -> dict: def get_settings(profile_id: int) -> dict: with connect() as conn: - row = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone() + row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone() + if not row: + # Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read. + legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone() + if legacy: + try: + settings = normalize_settings(json.loads(legacy.get("value") or "{}")) + except Exception: + settings = normalize_settings({}) + conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow())) + return settings try: - data = json.loads(row.get("value") or "{}") if row else {} + data = json.loads(row.get("settings_json") or "{}") if row else {} except Exception: data = {} return normalize_settings(data) @@ -84,7 +94,7 @@ def get_settings(profile_id: int) -> dict: def save_settings(profile_id: int, data: dict) -> dict: settings = normalize_settings(data) with connect() as conn: - conn.execute("INSERT OR REPLACE INTO app_settings(key,value) VALUES(?,?)", (_key(profile_id), json.dumps(settings))) + conn.execute("INSERT OR REPLACE INTO poller_settings(profile_id,settings_json,updated_at) VALUES(?,?,?)", (int(profile_id), json.dumps(settings), utcnow())) return settings diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 569c12d..8dd62aa 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -58,17 +58,21 @@ def recommended_table_columns_json() -> str: return json.dumps(RECOMMENDED_TABLE_COLUMNS, separators=(",", ":")) -def apply_recommended_table_columns(user_id: int | None = None): +def apply_recommended_table_columns(user_id: int | None = None, profile_id: int | None = None): user_id = user_id or auth.current_user_id() or default_user_id() - get_preferences(user_id) + profile_id = profile_id or _active_profile_id_for_user(user_id) + if not profile_id: + return get_preferences(user_id) + get_preferences(user_id, profile_id) now = utcnow() value = recommended_table_columns_json() with connect() as conn: conn.execute( - "UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", - (value, now, user_id), + "INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,created_at,updated_at) VALUES(?,?,?,?,?) " + "ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, updated_at=excluded.updated_at", + (user_id, profile_id, value, now, now), ) - return get_preferences(user_id) + return get_preferences(user_id, profile_id) def bootstrap_css_url(theme: str | None) -> str: from .frontend_assets import bootstrap_css_path @@ -317,31 +321,137 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i return clean +PROFILE_PREFERENCE_COLUMNS = { + "table_columns_json", + "torrent_sort_json", + "active_filter", + "peers_refresh_seconds", + "port_check_enabled", + "tracker_favicons_enabled", + "reverse_dns_enabled", +} + + +def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict: + now = utcnow() + legacy = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() or {} + row = conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() + if row: + return dict(row) + # Note: First profile preference row is seeded from legacy user-level values so upgrades keep the current layout/filter behavior. + conn.execute( + "INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + ( + user_id, + profile_id, + legacy.get("table_columns_json"), + legacy.get("torrent_sort_json"), + legacy.get("active_filter") or "all", + int(legacy.get("peers_refresh_seconds") or 0), + int(legacy.get("port_check_enabled") or 0), + int(legacy.get("tracker_favicons_enabled") or 0), + int(legacy.get("reverse_dns_enabled") or 0), + now, + now, + ), + ) + return dict(conn.execute("SELECT * FROM profile_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() or {}) + + +def get_profile_preferences(user_id: int, profile_id: int | None) -> dict: + if not profile_id: + return {} + with connect() as conn: + return _seed_profile_preferences(conn, user_id, int(profile_id)) + + +def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -> None: + if not profile_id: + return + profile_id = int(profile_id) + now = utcnow() + with connect() as conn: + current = _seed_profile_preferences(conn, user_id, profile_id) + updates: dict[str, object] = {} + if data.get("table_columns_json") is not None: + updates["table_columns_json"] = str(data.get("table_columns_json")) + if data.get("peers_refresh_seconds") is not None: + sec = int(data.get("peers_refresh_seconds") or 0) + updates["peers_refresh_seconds"] = sec if sec in {0, 10, 15, 30, 60} else 0 + if data.get("port_check_enabled") is not None: + updates["port_check_enabled"] = 1 if data.get("port_check_enabled") else 0 + if data.get("tracker_favicons_enabled") is not None: + updates["tracker_favicons_enabled"] = 1 if data.get("tracker_favicons_enabled") else 0 + if data.get("reverse_dns_enabled") is not None: + # Note: Reverse DNS is stored per profile because PTR lookups depend on swarm size and profile network latency. + updates["reverse_dns_enabled"] = 1 if data.get("reverse_dns_enabled") else 0 + if data.get("torrent_sort_json") is not None: + value = data.get("torrent_sort_json") if isinstance(data.get("torrent_sort_json"), str) else json.dumps(data.get("torrent_sort_json")) + parsed = json.loads(value or "{}") + if not isinstance(parsed, dict): + parsed = {} + try: + direction = int(parsed.get("dir") or 1) + except (TypeError, ValueError): + direction = 1 + allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"} + sort_key = str(parsed.get("key") or "name") + if sort_key not in allowed_sort_keys: + sort_key = "name" + updates["torrent_sort_json"] = json.dumps({"key": sort_key, "dir": 1 if direction >= 0 else -1}) + if data.get("active_filter") is not None: + value = str(data.get("active_filter") or "all").strip() + if not value or len(value) > 180: + value = "all" + allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"} + if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"): + value = "all" + updates["active_filter"] = value + if not updates: + return + merged = {**current, **updates} + conn.execute( + "INSERT INTO profile_preferences(user_id,profile_id,table_columns_json,torrent_sort_json,active_filter,peers_refresh_seconds,port_check_enabled,tracker_favicons_enabled,reverse_dns_enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(user_id,profile_id) DO UPDATE SET table_columns_json=excluded.table_columns_json, torrent_sort_json=excluded.torrent_sort_json, active_filter=excluded.active_filter, peers_refresh_seconds=excluded.peers_refresh_seconds, port_check_enabled=excluded.port_check_enabled, tracker_favicons_enabled=excluded.tracker_favicons_enabled, reverse_dns_enabled=excluded.reverse_dns_enabled, updated_at=excluded.updated_at", + ( + user_id, + profile_id, + merged.get("table_columns_json"), + merged.get("torrent_sort_json"), + merged.get("active_filter") or "all", + int(merged.get("peers_refresh_seconds") or 0), + int(merged.get("port_check_enabled") or 0), + int(merged.get("tracker_favicons_enabled") or 0), + int(merged.get("reverse_dns_enabled") or 0), + merged.get("created_at") or now, + now, + ), + ) + + def get_preferences(user_id: int | None = None, profile_id: int | None = None): user_id = user_id or auth.current_user_id() or default_user_id() + profile_id = profile_id or _active_profile_id_for_user(user_id) with connect() as conn: pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() if not pref: now = utcnow() conn.execute("INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(?, 'dark', ?, ?)", (user_id, now, now)) pref = conn.execute("SELECT * FROM user_preferences WHERE user_id=?", (user_id,)).fetchone() - merged = dict(pref or {}) + merged = dict(pref or {}) + if profile_id: + merged.update(_seed_profile_preferences(conn, user_id, int(profile_id))) merged.update(get_disk_monitor_preferences(profile_id, user_id)) return merged - def save_preferences(data: dict, user_id: int | None = None): user_id = user_id or auth.current_user_id() or default_user_id() + profile_id = _active_profile_id_for_user(user_id) allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None - table_columns_json = data.get("table_columns_json") - peers_refresh_seconds = data.get("peers_refresh_seconds") - port_check_enabled = data.get("port_check_enabled") footer_items_json = data.get("footer_items_json") title_speed_enabled = data.get("title_speed_enabled") - tracker_favicons_enabled = data.get("tracker_favicons_enabled") - reverse_dns_enabled = data.get("reverse_dns_enabled") automation_toasts_enabled = data.get("automation_toasts_enabled") smart_queue_toasts_enabled = data.get("smart_queue_toasts_enabled") disk_monitor_paths_json = data.get("disk_monitor_paths_json") @@ -352,8 +462,6 @@ def save_preferences(data: dict, user_id: int | None = None): interface_scale = data.get("interface_scale") compact_torrent_list_enabled = data.get("compact_torrent_list_enabled") detail_panel_height = data.get("detail_panel_height") - torrent_sort_json = data.get("torrent_sort_json") - active_filter = data.get("active_filter") disk_payload = None if any(value is not None for value in (disk_monitor_paths_json, disk_monitor_mode, disk_monitor_selected_path, disk_monitor_stop_enabled, disk_monitor_stop_threshold)): disk_payload = { @@ -371,21 +479,8 @@ def save_preferences(data: dict, user_id: int | None = None): conn.execute("UPDATE user_preferences SET bootstrap_theme=?, updated_at=? WHERE user_id=?", (bootstrap_theme, now, user_id)) if font_family: conn.execute("UPDATE user_preferences SET font_family=?, updated_at=? WHERE user_id=?", (font_family, now, user_id)) - if table_columns_json is not None: - conn.execute("UPDATE user_preferences SET table_columns_json=?, updated_at=? WHERE user_id=?", (str(table_columns_json), now, user_id)) - if peers_refresh_seconds is not None: - sec = int(peers_refresh_seconds or 0) - if sec not in {0, 10, 15, 30, 60}: sec = 0 - conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id)) - if port_check_enabled is not None: - conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id)) if title_speed_enabled is not None: conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id)) - if tracker_favicons_enabled is not None: - conn.execute("UPDATE user_preferences SET tracker_favicons_enabled=?, updated_at=? WHERE user_id=?", (1 if tracker_favicons_enabled else 0, now, user_id)) - if reverse_dns_enabled is not None: - # Note: Reverse DNS is optional because peer PTR lookups can add latency on busy swarms. - conn.execute("UPDATE user_preferences SET reverse_dns_enabled=?, updated_at=? WHERE user_id=?", (1 if reverse_dns_enabled else 0, now, user_id)) if automation_toasts_enabled is not None: # Note: Lets users silence automation-created toast noise without hiding job/history data. conn.execute("UPDATE user_preferences SET automation_toasts_enabled=?, updated_at=? WHERE user_id=?", (1 if automation_toasts_enabled else 0, now, user_id)) @@ -415,30 +510,7 @@ def save_preferences(data: dict, user_id: int | None = None): if height < 160: height = 160 if height > 720: height = 720 conn.execute("UPDATE user_preferences SET detail_panel_height=?, updated_at=? WHERE user_id=?", (height, now, user_id)) - if torrent_sort_json is not None: - # Note: Persist only a compact sort object; unknown keys are ignored on the client. - value = torrent_sort_json if isinstance(torrent_sort_json, str) else json.dumps(torrent_sort_json) - parsed = json.loads(value or "{}") - if not isinstance(parsed, dict): - parsed = {} - try: - direction = int(parsed.get("dir") or 1) - except (TypeError, ValueError): - direction = 1 - allowed_sort_keys = {"name", "status", "size", "progress", "down_rate", "up_rate", "eta", "seeds", "peers", "ratio", "path", "label", "ratio_group", "down_total", "to_download", "up_total", "created", "priority", "state", "active", "complete", "hashing", "message", "hash"} - sort_key = str(parsed.get("key") or "name") - if sort_key not in allowed_sort_keys: - sort_key = "name" - clean = {"key": sort_key, "dir": 1 if direction >= 0 else -1} - conn.execute("UPDATE user_preferences SET torrent_sort_json=?, updated_at=? WHERE user_id=?", (json.dumps(clean), now, user_id)) - if active_filter is not None: - value = str(active_filter or "all").strip() - if not value or len(value) > 180: - value = "all" - allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "stopped", "moving"} - if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"): - value = "all" - conn.execute("UPDATE user_preferences SET active_filter=?, updated_at=? WHERE user_id=?", (value, now, user_id)) + save_profile_preferences(user_id, profile_id, data) if disk_payload is not None: - save_disk_monitor_preferences(_active_profile_id_for_user(user_id), disk_payload, user_id) - return get_preferences(user_id) + save_disk_monitor_preferences(profile_id, disk_payload, user_id) + return get_preferences(user_id, profile_id) diff --git a/pytorrent/services/rss.py b/pytorrent/services/rss.py index 5e861db..97e8450 100644 --- a/pytorrent/services/rss.py +++ b/pytorrent/services/rss.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone, timedelta from email.utils import parsedate_to_datetime from typing import Iterable -from ..db import connect, utcnow, default_user_id +from ..db import connect, utcnow from . import rtorrent from .workers import enqueue @@ -122,12 +122,12 @@ def matches_rule(rule: dict, item: dict) -> tuple[bool, str]: return True, "matched" -def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None: +def _log(profile_id: int, feed_id: int | None, rule_id: int | None, item: dict, status: str, message: str) -> None: with connect() as conn: try: conn.execute( - "INSERT INTO rss_history(user_id,profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?,?)", - (user_id, profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()), + "INSERT INTO rss_history(profile_id,feed_id,rule_id,title,link,status,message,created_at) VALUES(?,?,?,?,?,?,?,?)", + (profile_id, feed_id, rule_id, item.get("title"), item.get("link"), status, message, utcnow()), ) except Exception: # Note: Duplicate successful RSS matches are ignored to prevent recurring duplicate downloads. @@ -135,15 +135,14 @@ def _log(user_id: int, profile_id: int, feed_id: int | None, rule_id: int | None def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> dict: - user_id = user_id or default_user_id() profile_id = int(profile["id"]) now = utcnow() with connect() as conn: if only_due: - feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (user_id, profile_id, now)).fetchall() + feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1 AND (next_check_at IS NULL OR next_check_at<=?)", (profile_id, now)).fetchall() else: - feeds = conn.execute("SELECT * FROM rss_feeds WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() - rules = conn.execute("SELECT * FROM rss_rules WHERE user_id=? AND profile_id=? AND enabled=1", (user_id, profile_id)).fetchall() + feeds = conn.execute("SELECT * FROM rss_feeds WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall() + rules = conn.execute("SELECT * FROM rss_rules WHERE profile_id=? AND enabled=1", (profile_id,)).fetchall() queued = 0 tested = 0 errors: list[dict] = [] @@ -160,11 +159,11 @@ def check(profile: dict, user_id: int | None = None, only_due: bool = False) -> continue link = item.get("link") or "" if not link: - _log(user_id, profile_id, feed["id"], rule["id"], item, "skipped", "missing link") + _log(profile_id, feed["id"], rule["id"], item, "skipped", "missing link") continue enqueue("add_magnet", profile_id, {"uri": link, "start": bool(rule["start"]), "directory": rule.get("save_path") or rtorrent.default_download_path(profile), "label": rule.get("label") or "", "source": "rss"}, user_id=user_id) queued += 1 - _log(user_id, profile_id, feed["id"], rule["id"], item, "queued", reason) + _log(profile_id, feed["id"], rule["id"], item, "queued", reason) with connect() as conn: conn.execute("UPDATE rss_feeds SET last_error=NULL,last_checked_at=?,next_check_at=?,updated_at=? WHERE id=?", (now, next_check, now, feed["id"])) except Exception as exc: @@ -200,11 +199,11 @@ def start_scheduler(socketio=None) -> None: try: from .preferences import get_profile with connect() as conn: - profiles = conn.execute("SELECT DISTINCT user_id, profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall() + profiles = conn.execute("SELECT DISTINCT profile_id FROM rss_feeds WHERE enabled=1 AND profile_id IS NOT NULL").fetchall() for row in profiles: - profile = get_profile(int(row["profile_id"]), int(row["user_id"])) + profile = get_profile(int(row["profile_id"])) if profile: - result = check(profile, int(row["user_id"]), only_due=True) + result = check(profile, only_due=True) if socketio and result.get("queued"): socketio.emit("rss_checked", {"profile_id": profile["id"], **result}, to=f"profile:{profile['id']}") except Exception: diff --git a/pytorrent/services/rtorrent/config.py b/pytorrent/services/rtorrent/config.py index 4494bd3..ab086a1 100644 --- a/pytorrent/services/rtorrent/config.py +++ b/pytorrent/services/rtorrent/config.py @@ -54,11 +54,10 @@ def _normalize_config_value(meta: dict, value): def saved_config_overrides(profile_id: int, user_id: int | None = None) -> dict[str, dict]: - user_id = user_id or default_user_id() with connect() as conn: rows = conn.execute( - "SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", - (user_id, int(profile_id)), + "SELECT key,value,baseline_value,apply_on_start,updated_at FROM rtorrent_config_overrides WHERE profile_id=?", + (int(profile_id),), ).fetchall() return {r["key"]: r for r in rows} @@ -129,7 +128,6 @@ def _read_rtorrent_config_value(client, key: str, meta: dict) -> str: def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, baseline_values: dict | None = None, clear_keys: list[str] | None = None) -> list[str]: known = {f["key"]: f for f in RTORRENT_CONFIG_FIELDS} - user_id = default_user_id() now = utcnow() profile_id = int(profile["id"]) baseline_values = baseline_values or {} @@ -139,8 +137,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba for key in clear_set: if key in known: conn.execute( - "DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", - (user_id, profile_id, key), + "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?", + (profile_id, key), ) for key, value in (values or {}).items(): if key in clear_set: @@ -150,8 +148,8 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba continue normalized = _normalize_config_value(meta, value) existing = conn.execute( - "SELECT baseline_value FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", - (user_id, profile_id, key), + "SELECT baseline_value FROM rtorrent_config_overrides WHERE profile_id=? AND key=?", + (profile_id, key), ).fetchone() existing_baseline = existing.get("baseline_value") if existing else None @@ -165,18 +163,18 @@ def store_config_overrides(profile: dict, values: dict, apply_on_start: bool, ba if baseline not in (None, "") and normalized == baseline: conn.execute( - "DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=? AND key=?", - (user_id, profile_id, key), + "DELETE FROM rtorrent_config_overrides WHERE profile_id=? AND key=?", + (profile_id, key), ) continue conn.execute( - "INSERT OR REPLACE INTO rtorrent_config_overrides(user_id,profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?,?)", - (user_id, profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now), + "INSERT OR REPLACE INTO rtorrent_config_overrides(profile_id,key,value,baseline_value,apply_on_start,updated_at) VALUES(?,?,?,?,?,?)", + (profile_id, key, normalized, baseline, 1 if apply_on_start else 0, now), ) stored.append(key) conn.execute( - "UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE user_id=? AND profile_id=?", - (1 if apply_on_start else 0, now, user_id, profile_id), + "UPDATE rtorrent_config_overrides SET apply_on_start=?, updated_at=? WHERE profile_id=?", + (1 if apply_on_start else 0, now, profile_id), ) return stored @@ -220,17 +218,16 @@ def set_config(profile: dict, values: dict, apply_now: bool = True, apply_on_sta def reset_config_overrides(profile: dict, user_id: int | None = None) -> dict: """Remove saved UI overrides and return the freshly read rTorrent config.""" # Note: Reset means "forget pyTorrent UI overrides"; it does not write defaults back to rTorrent. - user_id = user_id or default_user_id() profile_id = int(profile["id"]) with connect() as conn: row = conn.execute( - "SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", - (user_id, profile_id), + "SELECT COUNT(*) AS count FROM rtorrent_config_overrides WHERE profile_id=?", + (profile_id,), ).fetchone() removed = int((row or {}).get("count") or 0) conn.execute( - "DELETE FROM rtorrent_config_overrides WHERE user_id=? AND profile_id=?", - (user_id, profile_id), + "DELETE FROM rtorrent_config_overrides WHERE profile_id=?", + (profile_id,), ) config = get_config(profile) config["reset_removed"] = removed diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index 833bbc3..4ff7afd 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -135,9 +135,8 @@ def _int_setting(data: dict[str, Any], current: dict[str, Any], key: str, defaul return max(minimum, int(default)) -def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: +def _default_settings(profile_id: int) -> dict[str, Any]: return { - 'user_id': user_id, 'profile_id': profile_id, 'enabled': 0, 'max_active_downloads': 5, @@ -162,18 +161,16 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]: - user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( - 'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?', - (user_id, profile_id), + 'SELECT * FROM smart_queue_settings WHERE profile_id=?', + (profile_id,), ).fetchone() - settings = dict(row or _default_settings(user_id, profile_id)) + settings = dict(row or _default_settings(profile_id)) return settings def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: - user_id = user_id or default_user_id() current = get_settings(profile_id, user_id) settings = { 'enabled': 1 if data.get('enabled', current.get('enabled')) else 0, @@ -214,9 +211,9 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N now = utcnow() with connect() as conn: conn.execute( - '''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(user_id, profile_id) DO UPDATE SET + '''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(profile_id) DO UPDATE SET enabled=excluded.enabled, max_active_downloads=excluded.max_active_downloads, stalled_seconds=excluded.stalled_seconds, @@ -234,80 +231,74 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N refill_enabled=excluded.refill_enabled, refill_interval_minutes=excluded.refill_interval_minutes, updated_at=excluded.updated_at''', - (user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), + (profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), ) return get_settings(profile_id, user_id) def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]: - user_id = user_id or default_user_id() with connect() as conn: return conn.execute( - 'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC', - (user_id, profile_id), + 'SELECT * FROM smart_queue_exclusions WHERE profile_id=? ORDER BY created_at DESC', + (profile_id,), ).fetchall() def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None: - user_id = user_id or default_user_id() now = utcnow() with connect() as conn: if excluded: conn.execute( - 'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)', - (user_id, profile_id, torrent_hash, reason, now), + 'INSERT OR REPLACE INTO smart_queue_exclusions(profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?)', + (profile_id, torrent_hash, reason, now), ) else: conn.execute( - 'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?', - (user_id, profile_id, torrent_hash), + 'DELETE FROM smart_queue_exclusions WHERE profile_id=? AND torrent_hash=?', + (profile_id, torrent_hash), ) def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None: - user_id = user_id or default_user_id() paused = paused or [] resumed = resumed or [] details = details or {} with connect() as conn: conn.execute( - 'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)', - (user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), + 'INSERT INTO smart_queue_history(profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?)', + (profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), ) def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: - user_id = user_id or default_user_id() with connect() as conn: return conn.execute( - 'SELECT * FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', - (user_id, profile_id, max(1, min(int(limit or 30), 100))), + 'SELECT * FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT ?', + (profile_id, max(1, min(int(limit or 30), 100))), ).fetchall() def clear_history(profile_id: int, user_id: int | None = None) -> int: """Delete Smart Queue history rows for the current profile and return the removed count.""" # Note: Manual cleanup only removes audit history; settings, exclusions and pending queue state stay untouched. - user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( - 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', - (user_id, profile_id), + 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?', + (profile_id,), ).fetchone() count = int((row or {}).get('count') or 0) conn.execute( - 'DELETE FROM smart_queue_history WHERE user_id=? AND profile_id=?', - (user_id, profile_id), + 'DELETE FROM smart_queue_history WHERE profile_id=?', + (profile_id,), ) return count def count_history(profile_id: int, user_id: int | None = None) -> int: - user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( - 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', - (user_id, profile_id), + 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE profile_id=?', + (profile_id,), ).fetchone() return int((row or {}).get('count') or 0) @@ -315,11 +306,10 @@ def count_history(profile_id: int, user_id: int | None = None) -> int: def _latest_history_event(profile_id: int, user_id: int | None = None) -> str: """Return the newest Smart Queue history event for duplicate suppression.""" # Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream. - user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( - 'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1', - (user_id, profile_id), + 'SELECT event FROM smart_queue_history WHERE profile_id=? ORDER BY created_at DESC LIMIT 1', + (profile_id,), ).fetchone() return str((row or {}).get('event') or '') @@ -338,8 +328,8 @@ def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[s return True -def _excluded_hashes(profile_id: int, user_id: int) -> set[str]: - return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)} +def _excluded_hashes(profile_id: int, user_id: int | None = None) -> set[str]: + return {r['torrent_hash'] for r in list_exclusions(profile_id)} @@ -891,7 +881,7 @@ def _refill_mode(settings: dict[str, Any]) -> str: def _mark_refill_run(profile_id: int, user_id: int) -> None: # Note: Custom refill interval is measured from the last lightweight refill attempt. with connect() as conn: - conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) + conn.execute('UPDATE smart_queue_settings SET last_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id)) def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]: @@ -1090,13 +1080,13 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i def mark_run(profile_id: int, user_id: int | None = None) -> None: user_id = user_id or default_user_id() with connect() as conn: - conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (utcnow(), utcnow(), user_id, profile_id)) + conn.execute('UPDATE smart_queue_settings SET last_run_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id)) def _disable_when_idle(profile_id: int, user_id: int, torrents: list[dict[str, Any]], details: dict[str, Any]) -> dict[str, Any]: # Note: Auto-stop is intentionally profile-scoped and only flips the Smart Queue enabled flag; saved thresholds remain intact. now = utcnow() with connect() as conn: - conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE user_id=? AND profile_id=?', (now, now, user_id, profile_id)) + conn.execute('UPDATE smart_queue_settings SET enabled=0, last_run_at=?, updated_at=? WHERE profile_id=?', (now, now, profile_id)) add_history(profile_id, 'auto_stopped_idle', [], [], len(torrents), details, user_id) settings = get_settings(profile_id, user_id) return {'ok': True, 'enabled': False, 'auto_stopped_idle': True, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'checked': len(torrents), 'settings': settings, 'message': 'Smart Queue stopped because there is no active or waiting work.'} diff --git a/pytorrent/static/js/dashboard.js b/pytorrent/static/js/dashboard.js index 2f17661..f54fb7f 100644 --- a/pytorrent/static/js/dashboard.js +++ b/pytorrent/static/js/dashboard.js @@ -1 +1 @@ -export const dashboardSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentWarning(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction isPostCheckTorrent(t){ return t.status === 'Post-check' || !!t.post_check; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !isPostCheckTorrent(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const warning=torrentWarning(t);\n if(view==='smart:needs_attention') return !!warning || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentWarning(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>``).join('');\n return `
${esc(title)}${esc(rows.length)}
${esc(note)}
${sample||'No items.'}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent warning state.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`
    ${panes.map(p=>`
  • `).join('')}
${panes.map(p=>`
${p[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n box.innerHTML=`
${SMART_VIEW_DEFS.map(([key,label,note])=>``).join('')}
`;\n}\nfunction notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=` Notifications${count?` ${count}`:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=`
${esc(items.length)} saved event(s)
${items.map(x=>`
${esc(x.title)}${esc(x.message)}${esc(new Date(x.at).toLocaleString())}
`).join('')||'No notifications yet.'}
`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\nfunction diagnosticsSection(title, cards){\n return `
${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`
${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n}\nfunction ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML='
Torrent health
Live health buckets calculated from the current torrent snapshot.
';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML='
Smart Views
One-click filters for common torrent maintenance tasks.
';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML='
Notification center
Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.
';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n"; +export const dashboardSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentWarning(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction isPostCheckTorrent(t){ return t.status === 'Post-check' || !!t.post_check; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !isPostCheckTorrent(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const warning=torrentWarning(t);\n if(view==='smart:needs_attention') return !!warning || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentWarning(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>``).join('');\n return `
${esc(title)}${esc(rows.length)}
${esc(note)}
${sample||'No items.'}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent warning state.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`
    ${panes.map(p=>`
  • `).join('')}
${panes.map(p=>`
${p[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n // Note: The hint makes the card action explicit without changing existing filter behavior.\n box.innerHTML=`
Click any block to open the torrent list view filtered by that Smart View.
${SMART_VIEW_DEFS.map(([key,label,note])=>``).join('')}
`;\n}\nfunction notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=` Notifications${count?` ${count}`:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=`
${esc(items.length)} saved event(s)
${items.map(x=>`
${esc(x.title)}${esc(x.message)}${esc(new Date(x.at).toLocaleString())}
`).join('')||'No notifications yet.'}
`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\nfunction diagnosticsSection(title, cards){\n return `
${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`
${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n}\nfunction ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML='
Torrent health
Live health buckets calculated from the current torrent snapshot.
';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML='
Smart Views
One-click filters for common torrent maintenance tasks.
';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML='
Notification center
Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.
';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n"; diff --git a/pytorrent/static/js/messages.js b/pytorrent/static/js/messages.js index 6e5c220..1a5d306 100644 --- a/pytorrent/static/js/messages.js +++ b/pytorrent/static/js/messages.js @@ -1,152 +1 @@ -export const messagesSource = ` - - const APP_MESSAGES = { - actions: { - raw_torrent: 'Add torrent', - add_torrent_raw: 'Add torrent file', - add_magnet: 'Add magnet link', - add: 'Add torrent', - start: 'Start torrent', - pause: 'Pause torrent', - stop: 'Stop torrent', - resume: 'Resume torrent', - remove: 'Remove torrent', - erase: 'Delete torrent', - delete: 'Delete torrent', - move: 'Move torrent', - recheck: 'Force recheck', - reannounce: 'Reannounce', - set_label: 'Update label', - label: 'Update label' - }, - - toast: { - operationStarted: ({ action }) => \`\${actionLabel(action)} started\`, - - operationDone: ({ action }) => \`\${actionLabel(action)} done\`, - - operationFailed: ({ action, error }) => - \`\${actionLabel(action)} failed: \${error || 'unknown error'}\`, - - actionQueued: ({ action, parts }) => - Number(parts || 1) > 1 - ? \`\${actionLabel(action)} queued in \${parts} parts\` - : \`\${actionLabel(action)} queued\`, - - moveQueued: ({ parts, physical }) => - Number(parts || 1) > 1 - ? \`Move queued in \${parts} parts\` - : physical - ? 'Physical move queued' - : 'Move queued', - - addQueued: () => 'Torrent add queued', - - addQueuedSkipped: ({ count }) => - \`Torrent add queued, skipped \${count} duplicate torrent(s)\`, - - addTooLarge: () => - 'One or more .torrent files exceed the current rTorrent XML-RPC upload limit. Open rTorrent config and set network.xmlrpc.size_limit to e.g. 16M.', - - dropOnlyTorrents: () => 'Drop .torrent files only', - - droppedAddedSkipped: ({ queued, skipped }) => - \`Added \${queued} torrent(s), skipped \${skipped} duplicate(s)\`, - - droppedAdded: ({ queued }) => \`Added \${queued} torrent(s)\`, - - droppedSkipped: ({ skipped }) => - \`Skipped \${skipped} duplicate torrent(s)\`, - - droppedNone: () => 'No torrents were added', - - noTorrentsSelected: () => 'No torrents selected', - - noTorrentSelected: () => 'No torrent selected', - - noFilesSelected: () => 'No files selected', - - downloadStarted: () => 'Download started', - - chunkActionDone: ({ action }) => \`\${actionLabel(action)} done\`, - - trackerActionDone: ({ action }) => \`\${actionLabel(action)} done\`, - - pathPickerUnavailable: () => 'Path picker is unavailable', - - pathEmpty: () => 'Path is empty', - - columnsSaved: () => 'Columns saved', - - recommendedColumnsApplied: () => 'Recommended columns applied', - - jobLogsCleared: ({ deleted }) => - \`Cleared \${deleted || 0} finished job log(s)\`, - - emergencyJobLogsCleared: ({ deleted }) => - \`Emergency cleanup removed \${deleted || 0} job log(s)\`, - - rtorrentConfigSaved: ({ updated }) => - \`rTorrent config saved (\${updated || 0})\`, - - rtorrentConfigReset: ({ removed }) => - \`rTorrent config reset (\${removed || 0} override(s) removed)\`, - - automationLogsDeleted: ({ deleted }) => - \`Automation logs deleted: \${deleted || 0}\`, - - cleanupDone: ({ deleted }) => \`Cleanup done (\${deleted})\`, - - plannerApplied: ({ dryRun, paused, resumed, limitsChanged }) => - \`\${dryRun ? 'Planner dry-run' : 'Planner applied'}: paused \${paused || 0}, resumed \${resumed || 0}, limits \${limitsChanged ? 'changed' : 'unchanged'}\`, - - rssQueued: ({ queued }) => \`RSS queued \${queued || 0} item(s)\`, - - smartQueueCheckQueued: () => - 'Smart Queue check queued. It will continue in the background.', - - automationForceRunDone: ({ count }) => - \`Automation force run done (\${count || 0} torrent item(s))\`, - - automationsApplied: ({ count, batches }) => - batches - ? \`Automations applied \${count || 0} torrent(s) in \${batches || 0} batch(es)\` - : \`Automations applied \${count || 0} item(s)\`, - - torrentStatsError: ({ error }) => \`Torrent stats: \${error}\`, - - startupConfigApplied: ({ count }) => - \`Startup rTorrent config applied (\${count || 0})\`, - - startupConfigFailed: ({ error }) => - \`Startup rTorrent config: \${error}\`, - - plannerSocketResult: ({ paused, resumed, dryRun }) => - \`Planner: paused \${paused || 0}, resumed \${resumed || 0}\${dryRun ? ' dry-run' : ''}\` - } - }; - - function actionLabel(action) { - const key = String(action || '').trim(); - - if (APP_MESSAGES.actions[key]) { - return APP_MESSAGES.actions[key]; - } - - return key - ? key.replace(/[_-]+/g, ' ').replace(/\\b\\w/g, (c) => c.toUpperCase()) - : 'Operation'; - } - - function appMessage(key, params = {}) { - const fn = key - .split('.') - .reduce((acc, part) => acc && acc[part], APP_MESSAGES); - - return typeof fn === 'function' ? fn(params) : String(fn || key); - } - - function toastMessage(key, type = 'secondary', params = {}) { - toast(appMessage(key, params), type); - } -`; \ No newline at end of file +export const messagesSource = "\n\n const APP_MESSAGES = {\n actions: {\n raw_torrent: 'Add torrent',\n add_torrent_raw: 'Add torrent file',\n add_magnet: 'Add magnet link',\n add: 'Add torrent',\n start: 'Start torrent',\n pause: 'Pause torrent',\n stop: 'Stop torrent',\n resume: 'Resume torrent',\n remove: 'Remove torrent',\n erase: 'Delete torrent',\n delete: 'Delete torrent',\n move: 'Move torrent',\n recheck: 'Force recheck',\n reannounce: 'Reannounce',\n set_label: 'Update label',\n label: 'Update label'\n },\n\n toast: {\n operationStarted: ({ action }) => `${actionLabel(action)} started`,\n\n operationDone: ({ action }) => `${actionLabel(action)} done`,\n\n operationFailed: ({ action, error }) =>\n `${actionLabel(action)} failed: ${error || 'unknown error'}`,\n\n actionQueued: ({ action, parts }) =>\n Number(parts || 1) > 1\n ? `${actionLabel(action)} queued in ${parts} parts`\n : `${actionLabel(action)} queued`,\n\n moveQueued: ({ parts, physical }) =>\n Number(parts || 1) > 1\n ? `Move queued in ${parts} parts`\n : physical\n ? 'Physical move queued'\n : 'Move queued',\n\n addQueued: () => 'Torrent add queued',\n\n addQueuedSkipped: ({ count }) =>\n `Torrent add queued, skipped ${count} duplicate torrent(s)`,\n\n addTooLarge: () =>\n 'One or more .torrent files exceed the current rTorrent XML-RPC upload limit. Open rTorrent config and set network.xmlrpc.size_limit to e.g. 16M.',\n\n dropOnlyTorrents: () => 'Drop .torrent files only',\n\n droppedAddedSkipped: ({ queued, skipped }) =>\n `Added ${queued} torrent(s), skipped ${skipped} duplicate(s)`,\n\n droppedAdded: ({ queued }) => `Added ${queued} torrent(s)`,\n\n droppedSkipped: ({ skipped }) =>\n `Skipped ${skipped} duplicate torrent(s)`,\n\n droppedNone: () => 'No torrents were added',\n\n noTorrentsSelected: () => 'No torrents selected',\n\n noTorrentSelected: () => 'No torrent selected',\n\n noFilesSelected: () => 'No files selected',\n\n downloadStarted: () => 'Download started',\n\n chunkActionDone: ({ action }) => `${actionLabel(action)} done`,\n\n trackerActionDone: ({ action }) => `${actionLabel(action)} done`,\n\n pathPickerUnavailable: () => 'Path picker is unavailable',\n\n pathEmpty: () => 'Path is empty',\n\n columnsSaved: () => 'Columns saved',\n\n recommendedColumnsApplied: () => 'Recommended columns applied',\n\n jobLogsCleared: ({ deleted }) =>\n `Cleared ${deleted || 0} finished job log(s)`,\n\n emergencyJobLogsCleared: ({ deleted }) =>\n `Emergency cleanup removed ${deleted || 0} job log(s)`,\n\n rtorrentConfigSaved: ({ updated }) =>\n `rTorrent config saved (${updated || 0})`,\n\n rtorrentConfigReset: ({ removed }) =>\n `rTorrent config reset (${removed || 0} override(s) removed)`,\n\n automationLogsDeleted: ({ deleted }) =>\n `Automation logs deleted: ${deleted || 0}`,\n\n cleanupDone: ({ deleted }) => `Cleanup done (${deleted})`,\n\n plannerApplied: ({ dryRun, paused, resumed, limitsChanged }) =>\n `${dryRun ? 'Planner dry-run' : 'Planner applied'}: paused ${paused || 0}, resumed ${resumed || 0}, limits ${limitsChanged ? 'changed' : 'unchanged'}`,\n\n rssQueued: ({ queued }) => `RSS queued ${queued || 0} item(s)`,\n\n smartQueueCheckQueued: () =>\n 'Smart Queue check queued. It will continue in the background.',\n\n automationForceRunDone: ({ count }) =>\n `Automation force run done (${count || 0} torrent item(s))`,\n\n automationsApplied: ({ count, batches }) =>\n batches\n ? `Automations applied ${count || 0} torrent(s) in ${batches || 0} batch(es)`\n : `Automations applied ${count || 0} item(s)`,\n\n torrentStatsError: ({ error }) => `Torrent stats: ${error}`,\n\n startupConfigApplied: ({ count }) =>\n `Startup rTorrent config applied (${count || 0})`,\n\n startupConfigFailed: ({ error }) =>\n `Startup rTorrent config: ${error}`,\n\n plannerSocketResult: ({ paused, resumed, dryRun }) =>\n `Planner: paused ${paused || 0}, resumed ${resumed || 0}${dryRun ? ' dry-run' : ''}`\n }\n };\n\n function actionLabel(action) {\n const key = String(action || '').trim();\n\n if (APP_MESSAGES.actions[key]) {\n return APP_MESSAGES.actions[key];\n }\n\n return key\n ? key.replace(/[_-]+/g, ' ').replace(/\\\\b\\\\w/g, (c) => c.toUpperCase())\n : 'Operation';\n }\n\n function appMessage(key, params = {}) {\n const fn = key\n .split('.')\n .reduce((acc, part) => acc && acc[part], APP_MESSAGES);\n\n return typeof fn === 'function' ? fn(params) : String(fn || key);\n }\n\n function toastMessage(key, type = 'secondary', params = {}) {\n toast(appMessage(key, params), type);\n }\n"; diff --git a/pytorrent/static/js/mobile.js b/pytorrent/static/js/mobile.js index 039f60b..8f9c6b7 100644 --- a/pytorrent/static/js/mobile.js +++ b/pytorrent/static/js/mobile.js @@ -1 +1 @@ -export const mobileSource = " // Note: Mobile-only filtering, sorting and card rendering lives here so torrent table code stays focused on desktop rows.\n function enabledMobileSortSteps(){\n const enabled = MOBILE_SORT_STEPS.filter(step => mobileSortFilters[mobileSortStepId(step)]);\n return enabled.length ? enabled : MOBILE_SORT_STEPS.filter(step => DEFAULT_MOBILE_SORT_FILTER_IDS.has(mobileSortStepId(step)));\n }\n\n function mobileSortDef(){\n const steps = enabledMobileSortSteps();\n return steps.find(x=>x.key===sortState.key && x.dir===sortState.dir) || steps.find(x=>x.key===sortState.key) || steps[0] || MOBILE_SORT_STEPS[0];\n }\n\n function mobileSortLabel(){ const def=mobileSortDef(); return `${def.label} ${def.dir>0?'↑':'↓'}`; }\n\n function mobileSortSelectOptions(){\n const defs = [['name','Name'], ...COLUMN_DEFS.map(([key,label]) => [key,label])];\n const seen = new Set();\n return defs.filter(([key]) => { if(seen.has(key)) return false; seen.add(key); return SORT_KEYS.has(key); });\n }\n\n function setMobileSortValue(value){\n const [key, dir] = String(value || 'name:1').split(':');\n if(!SORT_KEYS.has(key)) return;\n sortState = {key, dir: Number(dir) < 0 ? -1 : 1};\n saveTorrentSortPreference();\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n scheduleRender(true);\n }\n\n function cycleMobileSort(){\n const steps = enabledMobileSortSteps();\n const current=steps.findIndex(x=>x.key===sortState.key && x.dir===sortState.dir);\n const next=steps[(current+1) % steps.length] || mobileSortDef();\n sortState={key:next.key, dir:next.dir};\n saveTorrentSortPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function setMobileFilterValue(value){\n const key=String(value||'all');\n mobileActiveFilterKey=key;\n if(key.startsWith('tracker:')){\n activeTrackerFilter=key.slice(8);\n activeFilter='all';\n }else{\n activeTrackerFilter='';\n activeFilter=key || 'all';\n }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function mobileFilterDefs(){\n const arr=trackerScopedRows();\n const defs=Object.keys(FILTER_COUNT_IDS).filter(k=>k!=='moving').map(k=>[k,k==='all'?'All':k==='downloading'?'Downloading':k==='seeding'?'Seeding':k==='paused'?'Paused':k==='checking'?'Checking':k==='error'?'With error':k==='post_check'?'Post-check':'Stopped',filterSummaryBucket(k).count||0]);\n const movingCount=movingFilterCount();\n if(movingCount) defs.push(['moving','Moving',movingCount]);\n const counts=new Map();\n arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label']));\n defs.push(['tracker:','All trackers',torrents.size,'tracker']);\n const trackerOptions=new Map((trackerSummary.trackers||[]).map(t=>[t.domain,t]));\n // Note: Preserve the selected tracker option even when the cache has not returned it yet, so the mobile select does not jump to All trackers.\n if(activeTrackerFilter && !trackerOptions.has(activeTrackerFilter)) trackerOptions.set(activeTrackerFilter, {domain:activeTrackerFilter, count:arr.length});\n [...trackerOptions.values()].forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker']));\n if(mobileSmartFiltersEnabled) SMART_VIEW_DEFS.forEach(([key,label])=>defs.push([key,label,arr.filter(t=>smartViewVisible(t,key)).length,'smart']));\n return defs;\n }\n\n function mobileVisibleRows(){\n // Note: Mobile bulk selection always uses the active mobile view: current filters, tracker/label scope, search and sort.\n return visibleRows.length ? visibleRows : [...torrents.values()].filter(rowVisible).sort(compareRows);\n }\n\n function mobileSelectionScopeHashes(rows = mobileVisibleRows()){\n return rows.map(t=>t.hash).filter(Boolean);\n }\n\n function setSelectionAnchorsFromCurrentSelection(){\n selectedHash = selected.size ? [...selected][selected.size - 1] : null;\n lastSelectedHash = selectedHash;\n }\n\n function toggleMobileVisibleSelection(){\n const hashes = mobileSelectionScopeHashes();\n const allSelected = hashes.length > 0 && hashes.every(hash=>selected.has(hash));\n // Note: Select all and Unselect all share one scope, so the button label and action stay in sync.\n hashes.forEach(hash=>allSelected ? selected.delete(hash) : selected.add(hash));\n setSelectionAnchorsFromCurrentSelection();\n }\n\n function renderMobileFilters(selectionRows = visibleRows){\n const bar=$('mobileFilterBar');\n if(!bar) return;\n const scopeRows = Array.isArray(selectionRows) ? selectionRows : visibleRows;\n const allVisible=scopeRows.length>0 && scopeRows.every(t=>selected.has(t.hash));\n const someVisible=scopeRows.some(t=>selected.has(t.hash));\n const defs=mobileFilterDefs();\n const currentSelect=$('mobileFilterSelect');\n const focused=currentSelect && document.activeElement===currentSelect;\n const sortSig = enabledMobileSortSteps().map(mobileSortStepId).join('|');\n const sig=[focused ? 'focus' : activeFilter, activeTrackerFilter, sortState.key, sortState.dir, selected.size, allVisible ? 1 : 0, someVisible ? 1 : 0, mobileSmartFiltersEnabled ? 1 : 0, sortSig, defs.map(d=>`${d[0]}:${d[2]}`).join('|')].join('::');\n if(focused) return;\n if(sig===lastMobileFiltersSignature) return;\n lastMobileFiltersSignature=sig;\n const selectedMobileKey = activeTrackerFilter ? `tracker:${activeTrackerFilter}` : (mobileActiveFilterKey === 'tracker:' ? 'tracker:' : activeFilter);\n // Note: Select exactly one mobile option; \"All\" and \"All trackers\" share the same data filter but must not both render as selected.\n const opts=defs.map(([key,label,count,type])=>{ const selectedOpt = key === selectedMobileKey; return ``; }).join('');\n const bulk=selected.size?``:'';\n // Note: Mobile sorting stays as a single cycle button while the step list covers every sortable column.\n bar.innerHTML=`
${bulk}${selected.size} selected
`;\n }\n\n function mobileColumnValue(t, key){\n if(key==='status') return statusBadge(t);\n if(key==='size') return `Size ${esc(t.size_h||'-')}`;\n if(key==='progress') return null;\n if(key==='down_rate') return `DL ${esc(t.down_rate_h||'-')}`;\n if(key==='up_rate') return `UL ${esc(t.up_rate_h||'-')}`;\n if(key==='eta') return `ETA ${esc(t.eta_h||'-')}`;\n if(key==='seeds') return `Seeds ${esc(t.seeds??0)}`;\n if(key==='peers') return `Peers ${esc(t.peers??0)}`;\n if(key==='ratio') return `Ratio ${esc(t.ratio??'-')}`;\n if(key==='label') return `Label ${esc(t.label||'-')}`;\n if(key==='ratio_group') return `Ratio group ${esc(t.ratio_group||'-')}`;\n if(key==='down_total') return `Downloaded ${esc(t.down_total_h||'-')}`;\n // Note: Complete torrents hide this mobile line because there is nothing left to download.\n if(key==='to_download') return t.to_download_h ? `To download ${esc(t.to_download_h)}` : null;\n if(key==='up_total') return `Uploaded ${esc(t.up_total_h||'-')}`;\n if(key==='created') return `Added ${esc(formatDateTime(t.created))}`;\n if(key==='priority') return `Priority ${esc(t.priority ?? '-')}`;\n if(key==='state') return `State ${esc(t.state ?? '-')}`;\n if(key==='active') return `Active ${esc(t.active ?? '-')}`;\n if(key==='complete') return `Complete ${esc(t.complete ?? '-')}`;\n if(key==='hashing') return `Hashing ${esc(t.hashing ?? 0)}`;\n if(key==='message') return `Message ${esc(t.message||'-')}`;\n if(key==='hash') return `Hash ${esc(t.hash||'-')}`;\n return null;\n }\n\n function mobileInfoLines(t){\n const primary=[], secondary=[];\n MOBILE_COLUMN_DEFS.forEach(([key])=>{\n if(!mobileColumns[key]) return;\n const value = mobileColumnValue(t, key);\n if(!value) return;\n if(['status','size','ratio','eta','seeds','peers'].includes(key)) primary.push(value);\n else if(key!=='path') secondary.push(value);\n });\n return {primary:primary.join(' · '), secondary:secondary.join(' · ')};\n }\n"; +export const mobileSource = " // Note: Mobile-only filtering, sorting and card rendering lives here so torrent table code stays focused on desktop rows.\n function enabledMobileSortSteps(){\n const enabled = MOBILE_SORT_STEPS.filter(step => mobileSortFilters[mobileSortStepId(step)]);\n return enabled.length ? enabled : MOBILE_SORT_STEPS.filter(step => DEFAULT_MOBILE_SORT_FILTER_IDS.has(mobileSortStepId(step)));\n }\n\n function mobileSortDef(){\n const steps = enabledMobileSortSteps();\n return steps.find(x=>x.key===sortState.key && x.dir===sortState.dir) || steps.find(x=>x.key===sortState.key) || steps[0] || MOBILE_SORT_STEPS[0];\n }\n\n function mobileSortLabel(){ return mobileSortDef().label; }\n\n function mobileSortIcon(){ return mobileSortDef().dir > 0 ? 'fa-arrow-up-wide-short' : 'fa-arrow-down-wide-short'; }\n\n function mobileSortSelectOptions(){\n const defs = [['name','Name'], ...COLUMN_DEFS.map(([key,label]) => [key,label])];\n const seen = new Set();\n return defs.filter(([key]) => { if(seen.has(key)) return false; seen.add(key); return SORT_KEYS.has(key); });\n }\n\n function setMobileSortValue(value){\n const [key, dir] = String(value || 'name:1').split(':');\n if(!SORT_KEYS.has(key)) return;\n sortState = {key, dir: Number(dir) < 0 ? -1 : 1};\n saveTorrentSortPreference();\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n scheduleRender(true);\n }\n\n function cycleMobileSort(){\n const steps = enabledMobileSortSteps();\n const current=steps.findIndex(x=>x.key===sortState.key && x.dir===sortState.dir);\n const next=steps[(current+1) % steps.length] || mobileSortDef();\n sortState={key:next.key, dir:next.dir};\n saveTorrentSortPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function setMobileFilterValue(value){\n const key=String(value||'all');\n mobileActiveFilterKey=key;\n if(key.startsWith('tracker:')){\n activeTrackerFilter=key.slice(8);\n activeFilter='all';\n }else{\n activeTrackerFilter='';\n activeFilter=key || 'all';\n }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function mobileFilterDefs(){\n const arr=trackerScopedRows();\n const defs=Object.keys(FILTER_COUNT_IDS).filter(k=>k!=='moving').map(k=>[k,k==='all'?'All':k==='downloading'?'Downloading':k==='seeding'?'Seeding':k==='paused'?'Paused':k==='checking'?'Checking':k==='error'?'With error':k==='post_check'?'Post-check':'Stopped',filterSummaryBucket(k).count||0]);\n const movingCount=movingFilterCount();\n if(movingCount) defs.push(['moving','Moving',movingCount]);\n const counts=new Map();\n arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label']));\n defs.push(['tracker:','All trackers',torrents.size,'tracker']);\n const trackerOptions=new Map((trackerSummary.trackers||[]).map(t=>[t.domain,t]));\n // Note: Preserve the selected tracker option even when the cache has not returned it yet, so the mobile select does not jump to All trackers.\n if(activeTrackerFilter && !trackerOptions.has(activeTrackerFilter)) trackerOptions.set(activeTrackerFilter, {domain:activeTrackerFilter, count:arr.length});\n [...trackerOptions.values()].forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker']));\n if(mobileSmartFiltersEnabled) SMART_VIEW_DEFS.forEach(([key,label])=>defs.push([key,label,arr.filter(t=>smartViewVisible(t,key)).length,'smart']));\n return defs;\n }\n\n function mobileVisibleRows(){\n // Note: Mobile bulk selection always uses the active mobile view: current filters, tracker/label scope, search and sort.\n return visibleRows.length ? visibleRows : [...torrents.values()].filter(rowVisible).sort(compareRows);\n }\n\n function mobileSelectionScopeHashes(rows = mobileVisibleRows()){\n return rows.map(t=>t.hash).filter(Boolean);\n }\n\n function setSelectionAnchorsFromCurrentSelection(){\n selectedHash = selected.size ? [...selected][selected.size - 1] : null;\n lastSelectedHash = selectedHash;\n }\n\n function toggleMobileVisibleSelection(){\n const hashes = mobileSelectionScopeHashes();\n const allSelected = hashes.length > 0 && hashes.every(hash=>selected.has(hash));\n // Note: Select all and Unselect all share one scope, so the button label and action stay in sync.\n hashes.forEach(hash=>allSelected ? selected.delete(hash) : selected.add(hash));\n setSelectionAnchorsFromCurrentSelection();\n }\n\n function renderMobileFilters(selectionRows = visibleRows){\n const bar=$('mobileFilterBar');\n if(!bar) return;\n const scopeRows = Array.isArray(selectionRows) ? selectionRows : visibleRows;\n const allVisible=scopeRows.length>0 && scopeRows.every(t=>selected.has(t.hash));\n const someVisible=scopeRows.some(t=>selected.has(t.hash));\n const defs=mobileFilterDefs();\n const currentSelect=$('mobileFilterSelect');\n const focused=currentSelect && document.activeElement===currentSelect;\n const sortSig = enabledMobileSortSteps().map(mobileSortStepId).join('|');\n const sig=[focused ? 'focus' : activeFilter, activeTrackerFilter, sortState.key, sortState.dir, selected.size, allVisible ? 1 : 0, someVisible ? 1 : 0, mobileSmartFiltersEnabled ? 1 : 0, sortSig, defs.map(d=>`${d[0]}:${d[2]}`).join('|')].join('::');\n if(focused) return;\n if(sig===lastMobileFiltersSignature) return;\n lastMobileFiltersSignature=sig;\n const selectedMobileKey = activeTrackerFilter ? `tracker:${activeTrackerFilter}` : (mobileActiveFilterKey === 'tracker:' ? 'tracker:' : activeFilter);\n // Note: Select exactly one mobile option; \"All\" and \"All trackers\" share the same data filter but must not both render as selected.\n const opts=defs.map(([key,label,count,type])=>{ const selectedOpt = key === selectedMobileKey; return ``; }).join('');\n const bulk=selected.size?``:'';\n // Note: Mobile sorting stays as a single cycle button while the step list covers every sortable column.\n bar.innerHTML=`
${bulk}${selected.size} selected
`;\n }\n\n function mobileColumnValue(t, key){\n if(key==='status') return statusBadge(t);\n if(key==='size') return `Size ${esc(t.size_h||'-')}`;\n if(key==='progress') return null;\n if(key==='down_rate') return `DL ${esc(t.down_rate_h||'-')}`;\n if(key==='up_rate') return `UL ${esc(t.up_rate_h||'-')}`;\n if(key==='eta') return `ETA ${esc(t.eta_h||'-')}`;\n if(key==='seeds') return `Seeds ${esc(t.seeds??0)}`;\n if(key==='peers') return `Peers ${esc(t.peers??0)}`;\n if(key==='ratio') return `Ratio ${esc(t.ratio??'-')}`;\n if(key==='label') return `Label ${esc(t.label||'-')}`;\n if(key==='ratio_group') return `Ratio group ${esc(t.ratio_group||'-')}`;\n if(key==='down_total') return `Downloaded ${esc(t.down_total_h||'-')}`;\n // Note: Complete torrents hide this mobile line because there is nothing left to download.\n if(key==='to_download') return t.to_download_h ? `To download ${esc(t.to_download_h)}` : null;\n if(key==='up_total') return `Uploaded ${esc(t.up_total_h||'-')}`;\n if(key==='created') return `Added ${esc(formatDateTime(t.created))}`;\n if(key==='priority') return `Priority ${esc(t.priority ?? '-')}`;\n if(key==='state') return `State ${esc(t.state ?? '-')}`;\n if(key==='active') return `Active ${esc(t.active ?? '-')}`;\n if(key==='complete') return `Complete ${esc(t.complete ?? '-')}`;\n if(key==='hashing') return `Hashing ${esc(t.hashing ?? 0)}`;\n if(key==='message') return `Message ${esc(t.message||'-')}`;\n if(key==='hash') return `Hash ${esc(t.hash||'-')}`;\n return null;\n }\n\n function mobileInfoLines(t){\n const primary=[], secondary=[];\n MOBILE_COLUMN_DEFS.forEach(([key])=>{\n if(!mobileColumns[key]) return;\n const value = mobileColumnValue(t, key);\n if(!value) return;\n if(['status','size','ratio','eta','seeds','peers'].includes(key)) primary.push(value);\n else if(key!=='path') secondary.push(value);\n });\n return {primary:primary.join(' · '), secondary:secondary.join(' · ')};\n }\n"; diff --git a/pytorrent/static/js/modals.js b/pytorrent/static/js/modals.js index c66ee70..6f4aee6 100644 --- a/pytorrent/static/js/modals.js +++ b/pytorrent/static/js/modals.js @@ -1 +1 @@ -export const modalsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `
${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'
No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`
${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n function columnPrefsPayload(){\n return JSON.stringify({hidden:cleanColumnPrefsHidden(hiddenColumns), shown:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS).filter(key => !hiddenColumns.has(key)), mobile:mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, widths:columnWidths});\n }\n function parseTableColumnsPreference(value){\n if(!value) return {};\n if(typeof value === 'object') return value;\n try{ return JSON.parse(value); }catch(e){ return {}; }\n }\n function applyTableColumnsPreference(value){\n const prefs = parseTableColumnsPreference(value);\n const explicitlyShown = new Set(prefs.shown || []);\n hiddenColumns = new Set([...(prefs.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShown.has(key))]);\n mobileColumns = normalizeMobileColumns(prefs.mobile || {});\n mobileSortFilters = normalizeMobileSortFilters(prefs.mobileSortFilters || {});\n mobileSmartFiltersEnabled = prefs.mobileSmartFiltersEnabled ?? true;\n columnWidths = normalizeColumnWidths(prefs.widths || {});\n saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths});\n }\n function renderColumnCards(defs, values, inputClass, dataAttr, icon){\n return defs.map(([key,label,hiddenByDefault])=>{\n const active = !!values[key];\n return ``;\n }).join('');\n }\n function renderMobileSortFilterCards(){\n return MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n const active = !!mobileSortFilters[id];\n const direction = step.dir > 0 ? 'ascending' : 'descending';\n return ``;\n }).join('');\n }\n function renderColumnManager(){\n const box=$('columnManager');\n if(!box) return;\n const desktopValues = Object.fromEntries(COLUMN_DEFS.map(([key])=>[key, !hiddenColumns.has(key)]));\n const desktop = renderColumnCards(COLUMN_DEFS, desktopValues, 'column-toggle', 'data-col-key', 'fa-table-columns');\n const mobile = renderColumnCards(MOBILE_COLUMN_DEFS, mobileColumns, 'mobile-column-toggle', 'data-mobile-col-key', 'fa-mobile-screen');\n const mobileSort = renderMobileSortFilterCards();\n const smart = ``;\n box.innerHTML=`
${desktop}
Mobile columns
${mobile}
Mobile sort filters
Only enabled sort choices appear in the mobile Sort button. Defaults keep seeds, upload, download and progress descending.
${mobileSort}
Mobile filter groups
${smart}
`;\n }\n $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); document.querySelectorAll('.mobile-column-toggle').forEach(cb=>mobileColumns[cb.dataset.mobileColKey]=cb.checked); document.querySelectorAll('.mobile-sort-filter-toggle').forEach(cb=>mobileSortFilters[cb.dataset.mobileSortFilter]=cb.checked); mobileSmartFiltersEnabled = $('mobileSmartFiltersToggle')?.checked ?? true; saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(e=>toast(e.message,'danger')); toastMessage('toast.columnsSaved','success'); });\n $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS); mobileColumns = normalizeMobileColumns(); mobileSortFilters = normalizeMobileSortFilters(); mobileSmartFiltersEnabled = true; columnWidths = normalizeColumnWidths(); saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(()=>{}); });\n $('recommendedColumnsBtn')?.addEventListener('click',async()=>{\n try{\n // Note: The recommended layout is applied by the backend and includes desktop, mobile and widths.\n const j = await post('/api/preferences/table-columns/recommended', {});\n applyTableColumnsPreference(j.preferences?.table_columns_json);\n renderColumnManager();\n applyColumnVisibility();\n scheduleRender(true);\n toastMessage('toast.recommendedColumnsApplied','success');\n }catch(e){\n toast(e.message,'danger');\n }\n });\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join('
') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)}`,\n r.source==='automation'?` automation`:(r.is_forced?' forced':'user'),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toastMessage('toast.jobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toastMessage('toast.emergencyJobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}
`).join(''):'
No labels.Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`
Groups
${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; +export const modalsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `
${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'
No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`
${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n function columnPrefsPayload(){\n return JSON.stringify({hidden:cleanColumnPrefsHidden(hiddenColumns), shown:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS).filter(key => !hiddenColumns.has(key)), mobile:mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, widths:columnWidths});\n }\n function parseTableColumnsPreference(value){\n if(!value) return {};\n if(typeof value === 'object') return value;\n try{ return JSON.parse(value); }catch(e){ return {}; }\n }\n function applyTableColumnsPreference(value){\n const prefs = parseTableColumnsPreference(value);\n const explicitlyShown = new Set(prefs.shown || []);\n hiddenColumns = new Set([...(prefs.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShown.has(key))]);\n mobileColumns = normalizeMobileColumns(prefs.mobile || {});\n mobileSortFilters = normalizeMobileSortFilters(prefs.mobileSortFilters || {});\n mobileSmartFiltersEnabled = prefs.mobileSmartFiltersEnabled ?? true;\n columnWidths = normalizeColumnWidths(prefs.widths || {});\n saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths});\n }\n function renderColumnCards(defs, values, inputClass, dataAttr, icon){\n return defs.map(([key,label,hiddenByDefault])=>{\n const active = !!values[key];\n return ``;\n }).join('');\n }\n function renderMobileSortFilterCards(){\n return MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n const active = !!mobileSortFilters[id];\n const direction = step.dir > 0 ? 'ascending' : 'descending';\n return ``;\n }).join('');\n }\n function renderColumnManager(){\n const box=$('columnManager');\n if(!box) return;\n const desktopValues = Object.fromEntries(COLUMN_DEFS.map(([key])=>[key, !hiddenColumns.has(key)]));\n const desktop = renderColumnCards(COLUMN_DEFS, desktopValues, 'column-toggle', 'data-col-key', 'fa-table-columns');\n const mobile = renderColumnCards(MOBILE_COLUMN_DEFS, mobileColumns, 'mobile-column-toggle', 'data-mobile-col-key', 'fa-mobile-screen');\n const mobileSort = renderMobileSortFilterCards();\n const smart = ``;\n box.innerHTML=`
${desktop}
Mobile columns
${mobile}
Mobile sort filters
Only enabled sort choices appear in the mobile Sort button. Defaults keep seeds, upload, download and progress descending.
${mobileSort}
Mobile filter groups
${smart}
`;\n }\n $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); document.querySelectorAll('.mobile-column-toggle').forEach(cb=>mobileColumns[cb.dataset.mobileColKey]=cb.checked); document.querySelectorAll('.mobile-sort-filter-toggle').forEach(cb=>mobileSortFilters[cb.dataset.mobileSortFilter]=cb.checked); mobileSmartFiltersEnabled = $('mobileSmartFiltersToggle')?.checked ?? true; saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(e=>toast(e.message,'danger')); toastMessage('toast.columnsSaved','success'); });\n $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS); mobileColumns = normalizeMobileColumns(); mobileSortFilters = normalizeMobileSortFilters(); mobileSmartFiltersEnabled = true; columnWidths = normalizeColumnWidths(); saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(()=>{}); });\n $('recommendedColumnsBtn')?.addEventListener('click',async()=>{\n try{\n // Note: The recommended layout is applied by the backend and includes desktop, mobile and widths.\n const j = await post('/api/preferences/table-columns/recommended', {});\n applyTableColumnsPreference(j.preferences?.table_columns_json);\n renderColumnManager();\n applyColumnVisibility();\n scheduleRender(true);\n toastMessage('toast.recommendedColumnsApplied','success');\n }catch(e){\n toast(e.message,'danger');\n }\n });\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join('
') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)}`,\n r.source==='automation'?` automation`:(r.is_forced?' forced':'user'),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toastMessage('toast.jobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toastMessage('toast.emergencyJobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}
`).join(''):'
No labels.Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`
Groups
${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; diff --git a/pytorrent/static/js/operationLogs.js b/pytorrent/static/js/operationLogs.js index 9e4953a..3ffdc02 100644 --- a/pytorrent/static/js/operationLogs.js +++ b/pytorrent/static/js/operationLogs.js @@ -1 +1 @@ -export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_started', 'job_done', 'job_failed'];\n function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')}`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `
${esc(label)}${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `
${esc(x.action)}${esc(x.n)}
`).join('');\n return `
${card('Total logs', stats.total || 0)}${types}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => ``).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n compactCell(r.details_h || '', 220),\n ]), 'operation-log-table');\n if(!rows.length) box.innerHTML = '
No logs.No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Page ${operationLogsPage + 1} / ${pages} · ${total} logs`;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `
${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n"; +export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_started', 'job_done', 'job_failed'];\n function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')}`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `
${esc(label)}${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `
${esc(x.action)}${esc(x.n)}
`).join('');\n return `
${card('Total logs', stats.total || 0)}${types}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => ``).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n compactCell(r.details_h || '', 220),\n ]), 'operation-log-table');\n if(!rows.length) box.innerHTML = '
No logs.No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Page ${operationLogsPage + 1} / ${pages} ${total} logs`;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `
${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n"; diff --git a/pytorrent/static/js/planner.js b/pytorrent/static/js/planner.js index 0a14bbd..dfb210d 100644 --- a/pytorrent/static/js/planner.js +++ b/pytorrent/static/js/planner.js @@ -1 +1 @@ -export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
One place for hourly speed limits, quiet hours and safety rules for the active rTorrent profile.
\n
${inlineSwitch('plannerEnabled')}
\n
\n
\n
\n
Basics
\n
\n \n \n \n \n
\n
\n
\n
Hourly speed planner
\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n
Fallback speed limits
\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n
Time windows
\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n
Protection
\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
Preview
No preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Adaptive WebSocket poller normal
Controls live refresh cadence per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','Clamp unsafe poller settings to known-safe intervals.')}\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n $('pollerReloadBtn')?.addEventListener('click',loadPollerSettings);\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function updatePlannerFooter(enabled,preview={}){ const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; +export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Adaptive WebSocket poller normal
Controls live refresh cadence per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','Clamp unsafe poller settings to known-safe intervals.')}\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n $('pollerReloadBtn')?.addEventListener('click',loadPollerSettings);\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; diff --git a/pytorrent/static/js/poller.js b/pytorrent/static/js/poller.js index 3579ecc..fc3a7b3 100644 --- a/pytorrent/static/js/poller.js +++ b/pytorrent/static/js/poller.js @@ -1 +1 @@ -export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms · gap ${esc(rt.last_tick_gap_ms||0)} ms · effective ${esc(rt.effective_interval_seconds||0)}s · min ${esc(rt.configured_min_interval_seconds||0)}s · payload ${esc(fmtBytes(rt.emitted_payload_size||0))} · rTorrent calls ${esc(rt.rtorrent_call_count||0)} · skipped ${esc(rt.skipped_emissions||0)} · mode ${esc(mode)} · adaptive ${adaptive?'on':'off'} · ok ${rt.last_ok?'yes':'no'} · ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('backupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup',{name:$('backupName')?.value||'Manual backup'}); toast('Backup created','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Backup schedule saved','success'); loadBackup();}); $('backupManager')?.addEventListener('click',async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ if(!confirm('Restore this backup and replace current app settings?')) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ toggleMobileVisibleSelection(); scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileDetails=e.target.closest('.mobile-details-btn'); if(mobileDetails){ const card0=mobileDetails.closest('.mobile-card'); if(card0?.dataset.hash) openMobileDetails(card0.dataset.hash); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download-link`).catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('compactTorrentListEnabled')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n"; +export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms · gap ${esc(rt.last_tick_gap_ms||0)} ms · effective ${esc(rt.effective_interval_seconds||0)}s · min ${esc(rt.configured_min_interval_seconds||0)}s · payload ${esc(fmtBytes(rt.emitted_payload_size||0))} · rTorrent calls ${esc(rt.rtorrent_call_count||0)} · skipped ${esc(rt.skipped_emissions||0)} · mode ${esc(mode)} · adaptive ${adaptive?'on':'off'} · ok ${rt.last_ok?'yes':'no'} · ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ toggleMobileVisibleSelection(); scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileDetails=e.target.closest('.mobile-details-btn'); if(mobileDetails){ const card0=mobileDetails.closest('.mobile-card'); if(card0?.dataset.hash) openMobileDetails(card0.dataset.hash); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download-link`).catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('compactTorrentListEnabled')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n"; diff --git a/pytorrent/static/js/rss.js b/pytorrent/static/js/rss.js index c857d4c..e2671e8 100644 --- a/pytorrent/static/js/rss.js +++ b/pytorrent/static/js/rss.js @@ -1 +1 @@ -export const rssSource = " async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[]; if($('rssManager')) $('rssManager').innerHTML=`
Feeds
${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),` `]))}
Rules
${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),` `]))}
RSS log
${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`; }\n \n\n function fillBackupSettings(settings={}){\n if($('backupAutoEnabled')) $('backupAutoEnabled').checked=!!settings.enabled;\n if($('backupAutoInterval')) $('backupAutoInterval').value=settings.interval_hours||24;\n if($('backupRetentionDays')) $('backupRetentionDays').value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '
No saved rows in this table.
';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`
${esc(t.name)}${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)${backupPreviewDetails(t)}
`).join('');\n return `
Backup preview
Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 sensitive values hidden
${rows || '
Backup has no previewable settings.
'}
`;\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n const rows=j.backups||[];\n fillBackupSettings(j.auto||{});\n if($('backupManager')) $('backupManager').innerHTML=responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`
Download
`]),'backup-table');\n }\n\n"; +export const rssSource = "\n async function loadRss(){\n const j=await (await fetch('/api/rss')).json();\n const feeds=j.feeds||[], rules=j.rules||[], history=j.history||[];\n if($('rssManager')) $('rssManager').innerHTML=`
Feeds
${table(['Name','URL','Interval','Last check','Last error','Actions'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.interval_minutes||30)+' min',humanDateCell(f.last_checked_at),esc(f.last_error||''),` `]))}
Rules
${table(['Name','Include','Exclude','Filters','Path','Label','Actions'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.exclude_pattern||''),esc([r.min_size_mb?`min ${r.min_size_mb}MB`:'',r.max_size_mb?`max ${r.max_size_mb}MB`:'',r.category,r.quality,r.season?`S${r.season}`:'',r.episode?`E${r.episode}`:''].filter(Boolean).join(', ')),esc(r.save_path),esc(r.label),` `]))}
RSS log
${table(['Time','Title','Status','Message'],history.map(h=>[humanDateCell(h.created_at),esc(h.title||h.link||''),esc(h.status),esc(h.message||'')]))}`;\n }\n\n function fillBackupSettings(settings={}, prefix='app'){\n const cap=prefix==='profile'?'Profile':'App';\n const enabled=$(prefix==='profile'?'profileBackupAutoEnabled':'backupAutoEnabled');\n const interval=$(prefix==='profile'?'profileBackupAutoInterval':'backupAutoInterval');\n const retention=$(prefix==='profile'?'profileBackupRetentionDays':'backupRetentionDays');\n if(enabled) enabled.checked=!!settings.enabled;\n if(interval) interval.value=settings.interval_hours||24;\n if(retention) retention.value=settings.retention_days||30;\n }\n function backupPreviewDetails(table={}){\n const sample=table.sample||[];\n if(!sample.length) return '
No saved rows in this table.
';\n const keys=[...new Set(sample.flatMap(row=>Object.keys(row||{})))].slice(0,8);\n return responsiveTable(keys.map(esc), sample.map(row=>keys.map(key=>esc(row?.[key] ?? ''))), 'backup-preview-sample-table');\n }\n function backupPreviewTable(preview={}){\n const tables=preview.tables||[];\n const rows=tables.map(t=>`
${esc(t.name)}${esc(t.rows)} row(s) \u00b7 ${(t.columns||[]).length} column(s)${backupPreviewDetails(t)}
`).join('');\n const type=preview.backup_type==='app'?'application':'profile';\n return `
Backup preview
${esc(type)} backup \u00b7 Created: ${esc(preview.created_at||'-')} \u00b7 ${preview.automatic?'automatic':'manual'} \u00b7 sensitive values hidden
${rows || '
Backup has no previewable settings.
'}
`;\n }\n function backupRows(rows=[]){\n return responsiveTable(['Name','Created','Type','Actions'],rows.map(b=>[esc(b.name),humanDateCell(b.created_at),b.automatic?'Auto':'Manual',`
Download
`]),'backup-table');\n }\n function switchBackupPane(pane){\n document.querySelectorAll('[data-backup-pane]').forEach(x=>x.classList.toggle('active',x.dataset.backupPane===pane));\n document.querySelectorAll('[data-backup-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.backupPanel!==pane));\n }\n async function loadBackup(){\n const j=await (await fetch('/api/backup')).json();\n fillBackupSettings(j.profile_auto||{}, 'profile');\n fillBackupSettings(j.app_auto||j.auto||{}, 'app');\n if($('profileBackupManager')) $('profileBackupManager').innerHTML=backupRows(j.profile_backups||[]);\n if($('appBackupManager')) $('appBackupManager').innerHTML=j.can_app_backup ? backupRows(j.app_backups||[]) : '
Application backups are admin-only.
';\n if(!j.can_app_backup) document.querySelector('[data-backup-pane=\"app\"]')?.classList.add('disabled');\n }\n"; diff --git a/pytorrent/static/js/smartQueue.js b/pytorrent/static/js/smartQueue.js index 2b5ca89..9500eda 100644 --- a/pytorrent/static/js/smartQueue.js +++ b/pytorrent/static/js/smartQueue.js @@ -1 +1 @@ -export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:smartHistoryDetails(latest||{}).decision||latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return '
No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' · ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(d.decision||h.event||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : '
No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function normalizeRtConfigValue(value, type='text'){\n const raw=String(value ?? '').trim();\n if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';\n if(type==='number'){\n if(raw==='') return '0';\n const normalized=Number(raw.replace(',', '.'));\n return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;\n }\n return raw;\n }\n function rtConfigInputValue(input){\n const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';\n const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;\n return normalizeRtConfigValue(value, type);\n }\n function rtConfigOriginalValue(input){\n const key=input.dataset.key;\n return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');\n }\n function collectRtConfigChanges(){\n const values={};\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled) return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur!==orig) values[input.dataset.key]=cur;\n });\n return values;\n }\n function collectRtConfigClearKeys(){\n const keys=[];\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled || input.dataset.saved!=='true') return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur===orig) keys.push(input.dataset.key);\n });\n return keys;\n }\n function updateRtConfigDirty(){\n const changed=collectRtConfigChanges();\n const clearKeys=collectRtConfigClearKeys();\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n const row=input.closest('.rt-config-row');\n if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));\n });\n const configChanges=Object.keys(changed).length;\n const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;\n const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);\n if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';\n if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;\n if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;\n }\n async function loadRtConfig(){\n const box=$('rtConfigManager');\n if(!box)return;\n box.innerHTML=' Loading config...';\n try{\n const j=await (await fetch('/api/rtorrent-config')).json();\n if(!j.ok) throw new Error(j.error||'Config load failed');\n const fields=j.config?.fields||[];\n rtConfigOriginal=new Map();\n rtConfigFieldTypes=new Map();\n rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;\n let lastGroup='';\n const html=fields.map(f=>{\n const group=f.group||'Other';\n const head=group!==lastGroup?`
${esc(group)}
`:'';\n lastGroup=group;\n const disabled=(!f.ok||f.readonly)?'disabled':'';\n const type=['bool','number'].includes(f.type)?f.type:'text';\n const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);\n const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);\n rtConfigOriginal.set(f.key, originalValue);\n rtConfigFieldTypes.set(f.key, type);\n const note=f.ok?(f.readonly?' · read only':(f.saved?' · saved override · reference kept':'')):' · unavailable';\n const valueNote=f.saved?`Reference: ${esc(originalValue)} → saved: ${esc(displayValue)}`:'';\n const originalAttr=esc(originalValue);\n const input=type==='bool'\n ? `${displayValue==='1'?'On':'Off'}`\n : ``;\n return `${head}`;\n }).join('');\n box.innerHTML=`
${html}
`;\n if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;\n updateRtConfigDirty();\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function saveRtConfig(){\n const values=collectRtConfigChanges();\n const clear_keys=collectRtConfigClearKeys();\n clear_keys.forEach(key=>{\n const input=document.querySelector(`.rt-config-input[data-key=\"${CSS.escape(key)}\"]`);\n if(input) values[key]=rtConfigOriginalValue(input);\n });\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});\n toastMessage('toast.rtorrentConfigSaved','success',{updated:j.result?.updated?.length});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function resetRtConfig(){\n // Note: Reset clears only saved UI overrides, then reloads the live state from rTorrent.\n if(!confirm('Clear all saved rTorrent UI overrides and reload current rTorrent values?')) return;\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config/reset',{});\n toastMessage('toast.rtorrentConfigReset','success',{removed:j.config?.reset_removed});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }\n\n function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){ bootstrapTheme = theme || \"default\"; const link=$(\"bootstrapThemeStylesheet\"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme; }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n function torrentRowHeight(){ return compactTorrentListEnabled ? COMPACT_ROW_HEIGHT : ROW_HEIGHT; }\n function applyCompactTorrentList(value){\n // Note: The compact switch changes density only; filtering, sorting and existing row actions stay unchanged.\n compactTorrentListEnabled = !!value;\n document.body.classList.toggle(\"compact-torrent-list\", compactTorrentListEnabled);\n if($(\"compactTorrentListEnabled\")) $(\"compactTorrentListEnabled\").checked = compactTorrentListEnabled;\n scheduleRender(true);\n }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); applyCompactTorrentList($(\"compactTorrentListEnabled\")?.checked); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale,compact_torrent_list_enabled:compactTorrentListEnabled}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n applyCompactTorrentList(compactTorrentListEnabled);\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n\n function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' → ')||'no actions';\n return `${cs} → ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' · ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `
${esc(r.name)} ${enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`
${cards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Logs and historyPending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `
${esc(label)}${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveReverseDnsPreference(){\n // Note: Reverse DNS remains opt-in and refreshes only the peers pane, leaving other torrent data untouched.\n reverseDnsEnabled=!!$('reverseDnsEnabled')?.checked;\n try{ await post('/api/preferences',{reverse_dns_enabled:reverseDnsEnabled}); if(activeTab()==='peers') loadDetails('peers'); toast('Reverse DNS preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function updateFooterClock(){\n const el=$('statClock');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});\n }\n function updateSocketStatus(s={}){\n const el=$('statSockets');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);\n const box=$('statusSockets');\n if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;\n }\n\n function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }\n function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }\n function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }\n function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)}`; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(' · ') || 'Port check disabled';\n if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id=\"portCheckBadge\" ');\n if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' · ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';\n if($('statusPortCheck')){\n $('statusPortCheck').classList.toggle('d-none', !data.enabled);\n $('statusPortCheck').title=title;\n }\n if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id=\"statusPortCheckBadge\" ',true);\n }\n async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\n function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')])],\n ['poller','Poller', diagnosticsSection('Adaptive poller', [diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)])],\n ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', `
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? '-')}${note?`${esc(note)}`:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`
    ${panes.map(p=>`
  • `).join('')}
${panes.map(p=>`
${p[2].join('')}
`).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=``;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return ``;\n }\n function plannerToggleRow(id,title,description){\n return `
${title}${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `
\n ${title}\n ${sub}\n
Unlimited
\n
\n \n \n \n \n \n \n
\n
\n \n \n \n \n
\n Slider uses Mbit/s. Numeric fields store B/s for rTorrent.\n
`;\n }\n"; +export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:smartHistoryDetails(latest||{}).decision||latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return '
No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' · ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(d.decision||h.event||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : '
No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function normalizeRtConfigValue(value, type='text'){\n const raw=String(value ?? '').trim();\n if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';\n if(type==='number'){\n if(raw==='') return '0';\n const normalized=Number(raw.replace(',', '.'));\n return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;\n }\n return raw;\n }\n function rtConfigInputValue(input){\n const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';\n const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;\n return normalizeRtConfigValue(value, type);\n }\n function rtConfigOriginalValue(input){\n const key=input.dataset.key;\n return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');\n }\n function collectRtConfigChanges(){\n const values={};\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled) return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur!==orig) values[input.dataset.key]=cur;\n });\n return values;\n }\n function collectRtConfigClearKeys(){\n const keys=[];\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled || input.dataset.saved!=='true') return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur===orig) keys.push(input.dataset.key);\n });\n return keys;\n }\n function updateRtConfigDirty(){\n const changed=collectRtConfigChanges();\n const clearKeys=collectRtConfigClearKeys();\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n const row=input.closest('.rt-config-row');\n if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));\n });\n const configChanges=Object.keys(changed).length;\n const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;\n const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);\n if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';\n if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;\n if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;\n }\n function renderRtConfigSummary(fields, applyOnStart){\n // Note: Current settings mirrors Diagnostics cards so values scan cleanly instead of reading like a dense sentence list.\n const total = fields.length;\n const saved = fields.filter(f=>f.saved).length;\n const unavailable = fields.filter(f=>!f.ok).length;\n const readonly = fields.filter(f=>f.readonly).length;\n const groups = new Set(fields.map(f=>f.group||'Other')).size;\n const cards = [\n diagCard('Known settings', total),\n diagCard('Saved overrides', saved),\n diagCard('Groups', groups),\n diagCard('Read only', readonly),\n diagCard('Unavailable', unavailable, unavailable ? 'diag-error' : ''),\n diagCard('Apply after start', applyOnStart ? 'on' : 'off'),\n ];\n return `
Current settings
${cards.join('')}
`;\n }\n\n async function loadRtConfig(){\n const box=$('rtConfigManager');\n if(!box)return;\n box.innerHTML=' Loading config...';\n try{\n const j=await (await fetch('/api/rtorrent-config')).json();\n if(!j.ok) throw new Error(j.error||'Config load failed');\n const fields=j.config?.fields||[];\n rtConfigOriginal=new Map();\n rtConfigFieldTypes=new Map();\n rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;\n let lastGroup='';\n const html=fields.map(f=>{\n const group=f.group||'Other';\n const head=group!==lastGroup?`
${esc(group)}
`:'';\n lastGroup=group;\n const disabled=(!f.ok||f.readonly)?'disabled':'';\n const type=['bool','number'].includes(f.type)?f.type:'text';\n const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);\n const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);\n rtConfigOriginal.set(f.key, originalValue);\n rtConfigFieldTypes.set(f.key, type);\n const note=f.ok?(f.readonly?' · read only':(f.saved?' · saved override · reference kept':'')):' · unavailable';\n const valueNote=f.saved?`Reference: ${esc(originalValue)} → saved: ${esc(displayValue)}`:'';\n const originalAttr=esc(originalValue);\n const input=type==='bool'\n ? `${displayValue==='1'?'On':'Off'}`\n : ``;\n return `${head}`;\n }).join('');\n box.innerHTML=`${renderRtConfigSummary(fields, rtConfigOriginalApplyOnStart)}
${html}
`;\n if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;\n updateRtConfigDirty();\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function saveRtConfig(){\n const values=collectRtConfigChanges();\n const clear_keys=collectRtConfigClearKeys();\n clear_keys.forEach(key=>{\n const input=document.querySelector(`.rt-config-input[data-key=\"${CSS.escape(key)}\"]`);\n if(input) values[key]=rtConfigOriginalValue(input);\n });\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});\n toastMessage('toast.rtorrentConfigSaved','success',{updated:j.result?.updated?.length});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function resetRtConfig(){\n // Note: Reset clears only saved UI overrides, then reloads the live state from rTorrent.\n if(!confirm('Clear all saved rTorrent UI overrides and reload current rTorrent values?')) return;\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config/reset',{});\n toastMessage('toast.rtorrentConfigReset','success',{removed:j.config?.reset_removed});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }\n\n function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){\n // Note: Custom Bootstrap 2-inspired themes are normal selectable themes and keep light/dark compatibility through data-bs-theme.\n bootstrapTheme = theme || \"default\";\n document.documentElement.dataset.bootstrapSkin = bootstrapTheme;\n const link=$(\"bootstrapThemeStylesheet\");\n if(link) link.href = bootstrapThemeUrl(bootstrapTheme);\n if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme;\n }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n function torrentRowHeight(){ return compactTorrentListEnabled ? COMPACT_ROW_HEIGHT : ROW_HEIGHT; }\n function applyCompactTorrentList(value){\n // Note: The compact switch changes density only; filtering, sorting and existing row actions stay unchanged.\n compactTorrentListEnabled = !!value;\n document.body.classList.toggle(\"compact-torrent-list\", compactTorrentListEnabled);\n if($(\"compactTorrentListEnabled\")) $(\"compactTorrentListEnabled\").checked = compactTorrentListEnabled;\n scheduleRender(true);\n }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); applyCompactTorrentList($(\"compactTorrentListEnabled\")?.checked); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale,compact_torrent_list_enabled:compactTorrentListEnabled}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n applyBootstrapTheme(bootstrapTheme);\n applyCompactTorrentList(compactTorrentListEnabled);\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function refreshPeersOnceForReverseDns(){\n // Note: Reverse DNS can resolve after the first peers fetch, so trigger one silent follow-up even when auto-refresh is disabled.\n if(activeTab()==='peers' && selectedHash){\n loadDetails('peers');\n setTimeout(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers',{silent:true}); }, 1200);\n }\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n\n function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' → ')||'no actions';\n return `${cs} → ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' · ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `
${esc(r.name)} ${enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`
${cards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Logs and historyPending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `
${esc(label)}${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveReverseDnsPreference(){\n // Note: Reverse DNS remains opt-in and refreshes only the peers pane, leaving other torrent data untouched.\n reverseDnsEnabled=!!$('reverseDnsEnabled')?.checked;\n try{ await post('/api/preferences',{reverse_dns_enabled:reverseDnsEnabled}); refreshPeersOnceForReverseDns(); toast('Reverse DNS preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function updateFooterClock(){\n const el=$('statClock');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});\n }\n function updateSocketStatus(s={}){\n const el=$('statSockets');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);\n const box=$('statusSockets');\n if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;\n }\n\n function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }\n function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }\n function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }\n function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)}`; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(' · ') || 'Port check disabled';\n if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id=\"portCheckBadge\" ');\n if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' · ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';\n if($('statusPortCheck')){\n $('statusPortCheck').classList.toggle('d-none', !data.enabled);\n $('statusPortCheck').title=title;\n }\n if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id=\"statusPortCheckBadge\" ',true);\n }\n async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\n function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')])],\n ['poller','Poller', diagnosticsSection('Adaptive poller', [diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)])],\n ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', `
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? '-')}${note?`${esc(note)}`:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`
    ${panes.map(p=>`
  • `).join('')}
${panes.map(p=>`
${p[2].join('')}
`).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=``;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return ``;\n }\n function plannerToggleRow(id,title,description){\n return `
${title}${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `
\n ${title}\n ${sub}\n
Unlimited
\n
\n \n \n \n \n \n \n
\n
\n \n \n \n \n
\n Slider uses Mbit/s. Numeric fields store B/s for rTorrent.\n
`;\n }\n"; diff --git a/pytorrent/static/js/state.js b/pytorrent/static/js/state.js index aa13c44..606bdb5 100644 --- a/pytorrent/static/js/state.js +++ b/pytorrent/static/js/state.js @@ -1 +1 @@ -export const stateSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n // Note: Footer transfer totals can arrive as already formatted strings, so keep this helper tolerant and side-effect free.\n function compactTransferText(value){\n const text = String(value ?? \"\").trim();\n if(!text) return \"-\";\n return text.replace(/\\\\s+/g, \" \");\n }\n const ROW_HEIGHT = 32, COMPACT_ROW_HEIGHT = 24, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n let compactTorrentListEnabled = Number(window.PYTORRENT?.compactTorrentListEnabled || 0) !== 0;\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Added\"},\n {key:\"created\", dir:1, label:\"Added\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Added\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_KEY = \"pytorrent.footerStatus.v1\";\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.textContent=`×${existing.count}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}×1`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)}` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$('mobileList');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; }\n function compactCell(value, max=120){ const text=String(value||\"\"); if(!text) return \"\"; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}…${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; }\n function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return `
${esc(pct)}%
`; }\n function progress(t){ return progressBar(t.progress); }\n"; +export const stateSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n // Note: Footer transfer totals can arrive as already formatted strings, so keep this helper tolerant and side-effect free.\n function compactTransferText(value){\n const text = String(value ?? \"\").trim();\n if(!text) return \"-\";\n return text.replace(/\\\\s+/g, \" \");\n }\n const ROW_HEIGHT = 32, COMPACT_ROW_HEIGHT = 24, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n let compactTorrentListEnabled = Number(window.PYTORRENT?.compactTorrentListEnabled || 0) !== 0;\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Added\"},\n {key:\"created\", dir:1, label:\"Added\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Added\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_KEY = \"pytorrent.footerStatus.v1\";\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.innerHTML=`${esc(existing.count)}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)}1`;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?`Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `
${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)}`; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
No rTorrent profile configured.Add the first rTorrent profile to start loading torrents.
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)}` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers.';\n return `
rTorrent is starting or not responding yet.Waiting for torrent data from the active profile.${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}`;\n const list=$('mobileList');\n if(list) list.innerHTML = `
${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))}`; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)}`; }\n function compactCell(value, max=120){ const text=String(value||\"\"); if(!text) return \"\"; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}...${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)}`; }\n function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return `
${esc(pct)}%
`; }\n function progress(t){ return progressBar(t.progress); }\n"; diff --git a/pytorrent/static/js/torrentDetails.js b/pytorrent/static/js/torrentDetails.js index f6f9255..82a94f7 100644 --- a/pytorrent/static/js/torrentDetails.js +++ b/pytorrent/static/js/torrentDetails.js @@ -1 +1 @@ -export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
${label}${value}
`).join('');\n $('detailPane').innerHTML=`\n
\n
\n
${esc(t.name||'-')}
${esc(t.status||'-')}
\n
Directory${esc(t.path||'-')}
\n
Full data path${esc(fullPath)}
\n
\n
Hash${esc(t.hash||'-')}
\n
\n
${cards}
\n
Labels${labels}
Ratio rule${ratioGroup}
Message${esc(t.message||'-')}
`;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download-link`).catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip/link`, {indexes});\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
${esc(label)}${mediaInfoValue(value)}
`).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
Detected metadata
${rows}
`;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
      ${raw || '
    • No metadata was returned for the sampled part of this file.
    • '}
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function mediaInfoPdfUrl(info){\n // Note: PDF preview links are created by the backend as short-lived app URLs, so the new-tab button does not expose /api/.\n return String(info.preview_url || '');\n }\n function renderPdfPreview(info){\n // Note: PDF preview uses the browser renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = ``;\n const openButton = src ? ` Open in new tab` : '';\n if(!src){\n return `
    PDF preview unavailableMissing temporary app link for inline preview.
    ${downloadButton}
    `;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n const expires = info.preview_expires_in ? ` · temporary link: ${Math.round(Number(info.preview_expires_in) / 60)} min` : '';\n return `
    PDF preview${esc(size)} · rendered by your browser${esc(expires)}
    ${openButton}${downloadButton}
    Inline PDF preview is not availableYour browser blocked the embedded viewer. Open it in a new tab or download the file.
    ${openButton}${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const button = document.querySelector(`#detailPane .file-media-info[data-index=\"${CSS.escape(String(index))}\"]`);\n if(button?.disabled){\n return toast('File info is available after this file is fully downloaded.','warning');\n }\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function fileInfoAvailable(f){\n // Note: File info is intentionally locked until rTorrent reports the selected file as fully downloaded.\n const size = Number(f?.size || 0);\n const progress = Number(f?.progress || 0);\n const completedChunks = Number(f?.completed_chunks || 0);\n const sizeChunks = Number(f?.size_chunks || 0);\n return size <= 0 || progress >= 100 || (sizeChunks > 0 && completedChunks >= sizeChunks);\n }\n function renderFileInfoButton(f){\n const available = fileInfoAvailable(f);\n const title = available ? 'File info / preview' : 'File info is available after this file is fully downloaded.';\n const disabled = available ? '' : ' disabled aria-disabled=\"true\"';\n const stateClass = available ? 'btn-outline-info' : 'btn-outline-secondary file-media-info-blocked';\n return ``;\n }\n function filesNeedAutoRefresh(files){\n // Note: The files list keeps refreshing only while at least one visible file is not fully downloaded.\n return (files || []).some(file => !fileInfoAvailable(file));\n }\n function clearFilesAutoRefresh(){\n // Note: Clearing the timer prevents hidden Files tabs and completed torrents from polling rTorrent.\n if(filesRefreshTimer) clearInterval(filesRefreshTimer);\n filesRefreshTimer = null;\n filesAutoRefreshHash = null;\n }\n function setupFilesAutoRefresh(files){\n // Note: Auto-refresh belongs to the open Files tab and is disabled as soon as all files reach 100%.\n const hash = selectedHash;\n if(activeTab() !== 'files' || !hash || !filesNeedAutoRefresh(files)){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshTimer && filesAutoRefreshHash === hash) return;\n clearFilesAutoRefresh();\n filesAutoRefreshHash = hash;\n filesRefreshTimer = setInterval(async () => {\n if(activeTab() !== 'files' || !selectedHash || filesAutoRefreshHash !== selectedHash){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshInFlight) return;\n filesRefreshInFlight = true;\n try{\n await loadDetails('files', {silent: true});\n }finally{\n filesRefreshInFlight = false;\n }\n }, FILES_AUTO_REFRESH_SECONDS * 1000);\n }\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    ${renderFileInfoButton(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent. File info becomes available only after a file reaches 100%.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n setupFilesAutoRefresh(files);\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=`
      ${fileTreeNode(j.tree||{})}
    `; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `
    ${esc(label)}${mobileDetailValue(value)}
    `).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')}`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '
    No peers returned by rTorrent.
    ';\n return responsiveTable(headers, rows, 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `
    ${renderFileInfoButton(file)}
    `;\n return [\n `${esc(file.path || file.name || '-')}`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)}`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '
    No files returned by rTorrent.
    ';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerItem(t){\n return `
  • ${esc(t.url || '-')}Seeds / Peers: ${esc(trackerSeedsPeers(t))}
  • `;\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `
    ${esc(title)}${meta ? `${esc(meta)}` : ''}
    `;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `
    ${titleMarkup}${body}
    `;\n }\n return `
    ${titleMarkup}${body}
    `;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    Torrent details
    Loading torrent details...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerList = trackers.slice(0, 12).map(mobileDetailsTrackerItem).join('') || '
  • No trackers returned by rTorrent.
  • ';\n const generalBody = `
    ${esc(t.name || '-')}
    Path${esc(fullPath)}
    Hash${esc(t.hash || '-')}
    ${mobileDetailsStatCards(t)}
    `;\n const messageBody = `
    ${esc(t.message || 'No message.')}
    `;\n const errorBox = failures.length ? `
    Partial details loaded
    ${esc(failures.join(' | '))}
    ` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', `
      ${trackerList}
    `, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '
    Loading peers, files and trackers...
    ';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `
    Details failed
    ${esc(e.message)}
    `;\n }\n }\n\n async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`
    Loading ${esc(tab)}...
    `;\n try{\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab()) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`
    ${esc(e.message)}
    `;\n }\n }\n"; +export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
    ${label}${value}
    `).join('');\n $('detailPane').innerHTML=`\n
    \n
    \n
    ${esc(t.name||'-')}
    ${esc(t.status||'-')}
    \n
    Directory${esc(t.path||'-')}
    \n
    Full data path${esc(fullPath)}
    \n
    \n
    Hash${esc(t.hash||'-')}
    \n
    \n
    ${cards}
    \n
    Labels${labels}
    Ratio rule${ratioGroup}
    Message${esc(t.message||'-')}
    `;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download-link`).catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip/link`, {indexes});\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} x ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
    ${esc(label)}${mediaInfoValue(value)}
    `).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
    Detected metadata
    ${rows}
    `;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
      ${raw || '
    • No metadata was returned for the sampled part of this file.
    • '}
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function mediaInfoPdfUrl(info){\n // Note: PDF preview links are created by the backend as short-lived app URLs, so the new-tab button does not expose /api/.\n return String(info.preview_url || '');\n }\n function renderPdfPreview(info){\n // Note: PDF preview uses the browser renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = ``;\n const openButton = src ? ` Open in new tab` : '';\n if(!src){\n return `
    PDF preview unavailableMissing temporary app link for inline preview.
    ${downloadButton}
    `;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n const expires = info.preview_expires_in ? ` · temporary link: ${Math.round(Number(info.preview_expires_in) / 60)} min` : '';\n return `
    PDF preview${esc(size)} · rendered by your browser${esc(expires)}
    ${openButton}${downloadButton}
    Inline PDF preview is not availableYour browser blocked the embedded viewer. Open it in a new tab or download the file.
    ${openButton}${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const button = document.querySelector(`#detailPane .file-media-info[data-index=\"${CSS.escape(String(index))}\"]`);\n if(button?.disabled){\n return toast('File info is available after this file is fully downloaded.','warning');\n }\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function fileInfoAvailable(f){\n // Note: File info is intentionally locked until rTorrent reports the selected file as fully downloaded.\n const size = Number(f?.size || 0);\n const progress = Number(f?.progress || 0);\n const completedChunks = Number(f?.completed_chunks || 0);\n const sizeChunks = Number(f?.size_chunks || 0);\n return size <= 0 || progress >= 100 || (sizeChunks > 0 && completedChunks >= sizeChunks);\n }\n function renderFileInfoButton(f){\n const available = fileInfoAvailable(f);\n const title = available ? 'File info / preview' : 'File info is available after this file is fully downloaded.';\n const disabled = available ? '' : ' disabled aria-disabled=\"true\"';\n const stateClass = available ? 'btn-outline-info' : 'btn-outline-secondary file-media-info-blocked';\n return ``;\n }\n function filesNeedAutoRefresh(files){\n // Note: The files list keeps refreshing only while at least one visible file is not fully downloaded.\n return (files || []).some(file => !fileInfoAvailable(file));\n }\n function clearFilesAutoRefresh(){\n // Note: Clearing the timer prevents hidden Files tabs and completed torrents from polling rTorrent.\n if(filesRefreshTimer) clearInterval(filesRefreshTimer);\n filesRefreshTimer = null;\n filesAutoRefreshHash = null;\n }\n function setupFilesAutoRefresh(files){\n // Note: Auto-refresh belongs to the open Files tab and is disabled as soon as all files reach 100%.\n const hash = selectedHash;\n if(activeTab() !== 'files' || !hash || !filesNeedAutoRefresh(files)){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshTimer && filesAutoRefreshHash === hash) return;\n clearFilesAutoRefresh();\n filesAutoRefreshHash = hash;\n filesRefreshTimer = setInterval(async () => {\n if(activeTab() !== 'files' || !selectedHash || filesAutoRefreshHash !== selectedHash){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshInFlight) return;\n filesRefreshInFlight = true;\n try{\n await loadDetails('files', {silent: true});\n }finally{\n filesRefreshInFlight = false;\n }\n }, FILES_AUTO_REFRESH_SECONDS * 1000);\n }\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    ${renderFileInfoButton(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent. File info becomes available only after a file reaches 100%.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n setupFilesAutoRefresh(files);\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=`
      ${fileTreeNode(j.tree||{})}
    `; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n // Note: Hostnames use the available peer-table space instead of a fixed character-style cap.\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,reverseDnsEnabled ? 'peers-table peers-table-hosts' : 'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `
    ${esc(label)}${mobileDetailValue(value)}
    `).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')}`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '
    No peers returned by rTorrent.
    ';\n return responsiveTable(headers, rows, reverseDnsEnabled ? 'peers-table mobile-details-peers-table peers-table-hosts' : 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `
    ${renderFileInfoButton(file)}
    `;\n return [\n `${esc(file.path || file.name || '-')}`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)}`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '
    No files returned by rTorrent.
    ';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerItem(t){\n return `
  • ${esc(t.url || '-')}Seeds / Peers: ${esc(trackerSeedsPeers(t))}
  • `;\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `
    ${esc(title)}${meta ? `${esc(meta)}` : ''}
    `;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `
    ${titleMarkup}${body}
    `;\n }\n return `
    ${titleMarkup}${body}
    `;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    Torrent details
    Loading torrent details...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerList = trackers.slice(0, 12).map(mobileDetailsTrackerItem).join('') || '
  • No trackers returned by rTorrent.
  • ';\n const generalBody = `
    ${esc(t.name || '-')}
    Path${esc(fullPath)}
    Hash${esc(t.hash || '-')}
    ${mobileDetailsStatCards(t)}
    `;\n const messageBody = `
    ${esc(t.message || 'No message.')}
    `;\n const errorBox = failures.length ? `
    Partial details loaded
    ${esc(failures.join(' | '))}
    ` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', `
      ${trackerList}
    `, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '
    Loading peers, files and trackers...
    ';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `
    Details failed
    ${esc(e.message)}
    `;\n }\n }\n\n async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`
    Loading ${esc(tab)}...
    `;\n try{\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab()) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`
    ${esc(e.message)}
    `;\n }\n }\n"; diff --git a/pytorrent/static/libs/pytorrent-themes/bootstrap22-inverse/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/bootstrap22-inverse/bootstrap.min.css new file mode 100644 index 0000000..cd225c3 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/bootstrap22-inverse/bootstrap.min.css @@ -0,0 +1,247 @@ +/* Note: Bootstrap 2 Inverse keeps the old beveled control language with darker navigation chrome. */ +:root { + --bs-border-radius: 4px; + --bs-border-radius-sm: 3px; + --bs-border-radius-lg: 5px; + --bs-font-sans-serif: "Helvetica Neue", Arial, sans-serif; +} + +[data-bs-theme="light"] { + --bs-body-bg: #eceff1; + --bs-body-color: #2f2f2f; + --bs-primary: #2f96b4; + --bs-primary-rgb: 47, 150, 180; + --bs-secondary-bg: #d9dee2; + --bs-secondary-bg-rgb: 217, 222, 226; + --bs-secondary-color: #4d5963; + --bs-tertiary-bg: #ffffff; + --bs-border-color: #b8c0c7; + --bs-link-color: #2f96b4; +} + +[data-bs-theme="dark"] { + --bs-body-bg: #161a1d; + --bs-body-color: #e7ecef; + --bs-primary: #49afcd; + --bs-primary-rgb: 73, 175, 205; + --bs-secondary-bg: #252b30; + --bs-secondary-bg-rgb: 37, 43, 48; + --bs-secondary-color: #c9d3da; + --bs-tertiary-bg: #20262a; + --bs-border-color: #48535c; + --bs-link-color: #5bc0de; +} + +/* Note: Bootstrap 2 surfaces were simple grey panels with subtle inset highlights. */ +.card, +.dropdown-menu, +.modal-content, +.surface-section, +.smart-setting-row, +.table, +.toast { + background-image: linear-gradient(#ffffff, #f7f7f7); + border: 1px solid var(--bs-border-color); + border-radius: 4px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08); +} + +[data-bs-theme="dark"] .card, +[data-bs-theme="dark"] .dropdown-menu, +[data-bs-theme="dark"] .modal-content, +[data-bs-theme="dark"] .surface-section, +[data-bs-theme="dark"] .smart-setting-row, +[data-bs-theme="dark"] .table, +[data-bs-theme="dark"] .toast { + background-image: linear-gradient(#303840, #252c33); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.25); +} + +.btn { + border-color: rgba(0, 0, 0, 0.22); + border-radius: 4px; + border-width: 1px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 1px 2px rgba(0, 0, 0, 0.08); + font-weight: 600; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25); + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease; +} + +.btn:hover { + filter: none; + text-decoration: none; +} + +.btn:focus-visible { + box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32); + filter: none; + text-decoration: none; +} + +.btn-primary { + background-image: linear-gradient(#08c, #04c); + border-color: #0044cc #0044cc #002a80; + color: #ffffff; +} + +.btn-primary:hover, +.btn-primary:focus-visible { + background-image: linear-gradient(#0077d9, #003bb3); + border-color: #003bb3 #003bb3 #001f66; +} + +.btn-success, +.btn-success:hover, +.btn-success:focus-visible { + background-image: linear-gradient(#62c462, #51a351); + border-color: #51a351 #51a351 #387038; + color: #ffffff; +} + +.btn-danger, +.btn-danger:hover, +.btn-danger:focus-visible { + background-image: linear-gradient(#ee5f5b, #bd362f); + border-color: #bd362f #bd362f #802420; + color: #ffffff; +} + +.btn-secondary, +.btn-outline-secondary, +.btn-light { + background-image: linear-gradient(#ffffff, #e6e6e6); + border-color: #cccccc #cccccc #b3b3b3; + color: #333333; +} + +.btn-outline-primary, +.btn-outline-success, +.btn-outline-danger, +.btn-outline-warning, +.btn-outline-info { + background-color: var(--bs-body-bg); +} + +.btn-outline-primary:hover, +.btn-outline-primary:focus-visible, +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + background-image: linear-gradient(#08c, #04c); + color: #ffffff; +} + +.btn-outline-success:hover, +.btn-outline-success:focus-visible { + background-image: linear-gradient(#62c462, #51a351); + color: #ffffff; +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background-image: linear-gradient(#ee5f5b, #bd362f); + color: #ffffff; +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus-visible { + background-image: linear-gradient(#fbb450, #f89406); + color: #111111; +} + +.btn-outline-info:hover, +.btn-outline-info:focus-visible { + background-image: linear-gradient(#5bc0de, #2f96b4); + color: #ffffff; +} + +.btn-secondary:hover, +.btn-secondary:focus-visible, +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background-image: linear-gradient(#e6e6e6, #cfcfcf); + border-color: #adadad; + color: #222222; +} + +[data-bs-theme="dark"] .btn-secondary, +[data-bs-theme="dark"] .btn-outline-secondary, +[data-bs-theme="dark"] .btn-light { + background-image: linear-gradient(#4b545d, #303840); + border-color: #5c6670; + color: #f0f0f0; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.45); +} + +[data-bs-theme="dark"] .btn-secondary:hover, +[data-bs-theme="dark"] .btn-secondary:focus-visible, +[data-bs-theme="dark"] .btn-outline-secondary:hover, +[data-bs-theme="dark"] .btn-outline-secondary:focus-visible { + background-image: linear-gradient(#68737e, #48515a); + color: #ffffff; +} + +.form-control, +.form-select, +.input-group-text { + border: 1px solid #cccccc; + border-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.form-control:focus, +.form-select:focus { + border-color: rgba(82, 168, 236, 0.8); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); +} + +.modal-header, +.card-header, +.dropdown-header, +.table thead th { + background-image: linear-gradient(#f9f9f9, #eeeeee); + border-bottom: 1px solid var(--bs-border-color); + color: var(--bs-body-color); + font-weight: 700; +} + +[data-bs-theme="dark"] .modal-header, +[data-bs-theme="dark"] .card-header, +[data-bs-theme="dark"] .dropdown-header, +[data-bs-theme="dark"] .table thead th { + background-image: linear-gradient(#39434c, #2c343b); +} + +.badge, +.label { + border-radius: 3px; +} + +.alert { + border-radius: 3px; + border-width: 1px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35); +} + +.progress { + background-image: linear-gradient(#f5f5f5, #e6e6e6); + border-radius: 3px; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); +} + +.progress-bar { + background-image: linear-gradient(#149bdf, #0480be); +} + +.table > :not(caption) > * > * { + border-bottom-color: var(--bs-border-color); +} + + +/* Note: Inverse variant reproduces the dark Bootstrap 2 navbar strip. */ +.navbar, +.sidebar, +.topbar { + background-image: linear-gradient(#444444, #222222); + border-color: #252525; + color: #eeeeee; +} diff --git a/pytorrent/static/libs/pytorrent-themes/bootstrap22/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/bootstrap22/bootstrap.min.css new file mode 100644 index 0000000..6b31652 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/bootstrap22/bootstrap.min.css @@ -0,0 +1,237 @@ +/* Note: Bootstrap 2 Classic maps the app UI to the old beveled Bootstrap 2 control language. */ +:root { + --bs-border-radius: 4px; + --bs-border-radius-sm: 3px; + --bs-border-radius-lg: 5px; + --bs-font-sans-serif: "Helvetica Neue", Arial, sans-serif; +} + +[data-bs-theme="light"] { + --bs-body-bg: #f5f5f5; + --bs-body-color: #333333; + --bs-primary: #006dcc; + --bs-primary-rgb: 0, 109, 204; + --bs-secondary-bg: #eeeeee; + --bs-secondary-bg-rgb: 238, 238, 238; + --bs-secondary-color: #555555; + --bs-tertiary-bg: #ffffff; + --bs-border-color: #c8c8c8; + --bs-link-color: #0088cc; +} + +[data-bs-theme="dark"] { + --bs-body-bg: #1f252b; + --bs-body-color: #e6e6e6; + --bs-primary: #4aa3df; + --bs-primary-rgb: 74, 163, 223; + --bs-secondary-bg: #2f363d; + --bs-secondary-bg-rgb: 47, 54, 61; + --bs-secondary-color: #c7d0d8; + --bs-tertiary-bg: #252b31; + --bs-border-color: #4a535c; + --bs-link-color: #6bbdf0; +} + +/* Note: Bootstrap 2 surfaces were simple grey panels with subtle inset highlights. */ +.card, +.dropdown-menu, +.modal-content, +.surface-section, +.smart-setting-row, +.table, +.toast { + background-image: linear-gradient(#ffffff, #f7f7f7); + border: 1px solid var(--bs-border-color); + border-radius: 4px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), 0 1px 2px rgba(0, 0, 0, 0.08); +} + +[data-bs-theme="dark"] .card, +[data-bs-theme="dark"] .dropdown-menu, +[data-bs-theme="dark"] .modal-content, +[data-bs-theme="dark"] .surface-section, +[data-bs-theme="dark"] .smart-setting-row, +[data-bs-theme="dark"] .table, +[data-bs-theme="dark"] .toast { + background-image: linear-gradient(#303840, #252c33); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.25); +} + +.btn { + border-color: rgba(0, 0, 0, 0.22); + border-radius: 4px; + border-width: 1px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38), 0 1px 2px rgba(0, 0, 0, 0.08); + font-weight: 600; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25); + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease; +} + +.btn:hover { + filter: none; + text-decoration: none; +} + +.btn:focus-visible { + box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32); + filter: none; + text-decoration: none; +} + +.btn-primary { + background-image: linear-gradient(#08c, #04c); + border-color: #0044cc #0044cc #002a80; + color: #ffffff; +} + +.btn-primary:hover, +.btn-primary:focus-visible { + background-image: linear-gradient(#0077d9, #003bb3); + border-color: #003bb3 #003bb3 #001f66; +} + +.btn-success, +.btn-success:hover, +.btn-success:focus-visible { + background-image: linear-gradient(#62c462, #51a351); + border-color: #51a351 #51a351 #387038; + color: #ffffff; +} + +.btn-danger, +.btn-danger:hover, +.btn-danger:focus-visible { + background-image: linear-gradient(#ee5f5b, #bd362f); + border-color: #bd362f #bd362f #802420; + color: #ffffff; +} + +.btn-secondary, +.btn-outline-secondary, +.btn-light { + background-image: linear-gradient(#ffffff, #e6e6e6); + border-color: #cccccc #cccccc #b3b3b3; + color: #333333; +} + +.btn-outline-primary, +.btn-outline-success, +.btn-outline-danger, +.btn-outline-warning, +.btn-outline-info { + background-color: var(--bs-body-bg); +} + +.btn-outline-primary:hover, +.btn-outline-primary:focus-visible, +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + background-image: linear-gradient(#08c, #04c); + color: #ffffff; +} + +.btn-outline-success:hover, +.btn-outline-success:focus-visible { + background-image: linear-gradient(#62c462, #51a351); + color: #ffffff; +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background-image: linear-gradient(#ee5f5b, #bd362f); + color: #ffffff; +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus-visible { + background-image: linear-gradient(#fbb450, #f89406); + color: #111111; +} + +.btn-outline-info:hover, +.btn-outline-info:focus-visible { + background-image: linear-gradient(#5bc0de, #2f96b4); + color: #ffffff; +} + +.btn-secondary:hover, +.btn-secondary:focus-visible, +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background-image: linear-gradient(#e6e6e6, #cfcfcf); + border-color: #adadad; + color: #222222; +} + +[data-bs-theme="dark"] .btn-secondary, +[data-bs-theme="dark"] .btn-outline-secondary, +[data-bs-theme="dark"] .btn-light { + background-image: linear-gradient(#4b545d, #303840); + border-color: #5c6670; + color: #f0f0f0; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.45); +} + +[data-bs-theme="dark"] .btn-secondary:hover, +[data-bs-theme="dark"] .btn-secondary:focus-visible, +[data-bs-theme="dark"] .btn-outline-secondary:hover, +[data-bs-theme="dark"] .btn-outline-secondary:focus-visible { + background-image: linear-gradient(#68737e, #48515a); + color: #ffffff; +} + +.form-control, +.form-select, +.input-group-text { + border: 1px solid #cccccc; + border-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.form-control:focus, +.form-select:focus { + border-color: rgba(82, 168, 236, 0.8); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); +} + +.modal-header, +.card-header, +.dropdown-header, +.table thead th { + background-image: linear-gradient(#f9f9f9, #eeeeee); + border-bottom: 1px solid var(--bs-border-color); + color: var(--bs-body-color); + font-weight: 700; +} + +[data-bs-theme="dark"] .modal-header, +[data-bs-theme="dark"] .card-header, +[data-bs-theme="dark"] .dropdown-header, +[data-bs-theme="dark"] .table thead th { + background-image: linear-gradient(#39434c, #2c343b); +} + +.badge, +.label { + border-radius: 3px; +} + +.alert { + border-radius: 3px; + border-width: 1px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35); +} + +.progress { + background-image: linear-gradient(#f5f5f5, #e6e6e6); + border-radius: 3px; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); +} + +.progress-bar { + background-image: linear-gradient(#149bdf, #0480be); +} + +.table > :not(caption) > * > * { + border-bottom-color: var(--bs-border-color); +} diff --git a/pytorrent/static/libs/pytorrent-themes/bootstrap3-inverse/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/bootstrap3-inverse/bootstrap.min.css new file mode 100644 index 0000000..5405f71 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/bootstrap3-inverse/bootstrap.min.css @@ -0,0 +1,268 @@ +/* Note: Bootstrap 3 Inverse keeps the dark navbar era with Bootstrap 3 panel and button shapes. */ +:root { + --bs-border-radius: 4px; + --bs-border-radius-sm: 3px; + --bs-border-radius-lg: 4px; + --bs-font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +[data-bs-theme="light"] { + --bs-body-bg: #eceff2; + --bs-body-color: #2d3338; + --bs-primary: #428bca; + --bs-primary-rgb: 66, 139, 202; + --bs-success: #5cb85c; + --bs-danger: #d9534f; + --bs-warning: #f0ad4e; + --bs-info: #5bc0de; + --bs-secondary-bg: #dfe4e8; + --bs-secondary-bg-rgb: 223, 228, 232; + --bs-secondary-color: #4c5963; + --bs-tertiary-bg: #ffffff; + --bs-border-color: #c5cdd4; + --bs-link-color: #2a6496; +} + +[data-bs-theme="dark"] { + --bs-body-bg: #151a1f; + --bs-body-color: #edf2f6; + --bs-primary: #6fb4eb; + --bs-primary-rgb: 111, 180, 235; + --bs-success: #7dd37d; + --bs-danger: #ec7773; + --bs-warning: #f6c572; + --bs-info: #83d8ee; + --bs-secondary-bg: #232b33; + --bs-secondary-bg-rgb: 35, 43, 51; + --bs-secondary-color: #c3ccd4; + --bs-tertiary-bg: #1d242b; + --bs-border-color: #3f4b56; + --bs-link-color: #94cdf5; +} + +/* Note: Bootstrap 3 panels and wells are represented through shared app containers. */ +.card, +.dropdown-menu, +.modal-content, +.surface-section, +.smart-setting-row, +.toast { + background-color: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 4px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); +} + +.navbar, +.topbar { + background-image: linear-gradient(#ffffff, #f2f2f2); + border-bottom: 1px solid var(--bs-border-color); +} + +[data-bs-theme="dark"] .navbar, +[data-bs-theme="dark"] .topbar { + background-image: linear-gradient(#303941, #20272e); +} + +.btn { + border-width: 1px; + border-radius: 4px; + font-weight: 600; + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease; +} + +.btn:hover { + filter: none; + text-decoration: none; +} + +.btn:focus-visible { + box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32); + filter: none; + text-decoration: none; +} + +.btn-primary { + background-image: linear-gradient(#428bca, #2d6ca2); + border-color: #2b669a; + color: #ffffff; +} + +.btn-primary:hover, +.btn-primary:focus-visible, +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + background-color: #2d6ca2; + color: #ffffff; +} + +.btn-success, +.btn-success:hover, +.btn-success:focus-visible { + background-color: #5cb85c; + border-color: #4cae4c; + color: #ffffff; +} + +.btn-danger, +.btn-danger:hover, +.btn-danger:focus-visible { + background-color: #d9534f; + border-color: #d43f3a; + color: #ffffff; +} + +.btn-warning, +.btn-warning:hover, +.btn-warning:focus-visible { + background-color: #f0ad4e; + border-color: #eea236; + color: #111111; +} + +.btn-info, +.btn-info:hover, +.btn-info:focus-visible { + background-color: #5bc0de; + border-color: #46b8da; + color: #ffffff; +} + +.btn-secondary, +.btn-outline-secondary, +.btn-light { + background-color: #ffffff; + border-color: #cccccc; + color: #333333; +} + +.btn-outline-primary, +.btn-outline-success, +.btn-outline-danger, +.btn-outline-warning, +.btn-outline-info { + background-color: var(--bs-body-bg); +} + +.btn-outline-primary:hover, +.btn-outline-primary:focus-visible { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: #ffffff; +} + +.btn-outline-success:hover, +.btn-outline-success:focus-visible { + background-color: var(--bs-success); + border-color: var(--bs-success); + color: #ffffff; +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background-color: var(--bs-danger); + border-color: var(--bs-danger); + color: #ffffff; +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus-visible { + background-color: var(--bs-warning); + border-color: var(--bs-warning); + color: #111111; +} + +.btn-outline-info:hover, +.btn-outline-info:focus-visible { + background-color: var(--bs-info); + border-color: var(--bs-info); + color: #111111; +} + +.btn-secondary:hover, +.btn-secondary:focus-visible, +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background-color: #e6e6e6; + border-color: #adadad; + color: #222222; +} + +[data-bs-theme="dark"] .btn-secondary, +[data-bs-theme="dark"] .btn-outline-secondary, +[data-bs-theme="dark"] .btn-light { + background-color: #323b44; + border-color: #53606b; + color: #edf2f6; +} + +[data-bs-theme="dark"] .btn-secondary:hover, +[data-bs-theme="dark"] .btn-secondary:focus-visible, +[data-bs-theme="dark"] .btn-outline-secondary:hover, +[data-bs-theme="dark"] .btn-outline-secondary:focus-visible { + background-color: #46515b; + color: #ffffff; +} + +.form-control, +.form-select, +.input-group-text { + border: 1px solid #cccccc; + border-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.form-control:focus, +.form-select:focus { + border-color: #66afe9; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6); +} + +.card-header, +.modal-header, +.table thead th { + background-color: #f5f5f5; + border-bottom: 1px solid var(--bs-border-color); + color: var(--bs-body-color); + font-weight: 700; +} + +[data-bs-theme="dark"] .card-header, +[data-bs-theme="dark"] .modal-header, +[data-bs-theme="dark"] .table thead th { + background-color: #303941; +} + +.badge, +.label { + border-radius: 4px; +} + +.alert { + border-radius: 4px; + border-width: 1px; +} + +.progress { + background-color: #f5f5f5; + border-radius: 4px; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.progress-bar { + background-image: linear-gradient(#5bc0de, #339bb9); +} + +.table > :not(caption) > * > * { + border-bottom-color: var(--bs-border-color); +} + + +/* Note: Inverse variant reproduces Bootstrap 3's dark default navbar treatment. */ +.navbar, +.sidebar, +.topbar { + background-image: linear-gradient(#3c3c3c, #222222); + border-color: #080808; + color: #eeeeee; +} diff --git a/pytorrent/static/libs/pytorrent-themes/bootstrap3/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/bootstrap3/bootstrap.min.css new file mode 100644 index 0000000..6c35f70 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/bootstrap3/bootstrap.min.css @@ -0,0 +1,258 @@ +/* Note: Bootstrap 3 Glyph uses flat panels, square controls and the Bootstrap 3 palette. */ +:root { + --bs-border-radius: 4px; + --bs-border-radius-sm: 3px; + --bs-border-radius-lg: 4px; + --bs-font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +[data-bs-theme="light"] { + --bs-body-bg: #f8f8f8; + --bs-body-color: #333333; + --bs-primary: #337ab7; + --bs-primary-rgb: 51, 122, 183; + --bs-success: #5cb85c; + --bs-danger: #d9534f; + --bs-warning: #f0ad4e; + --bs-info: #5bc0de; + --bs-secondary-bg: #eeeeee; + --bs-secondary-bg-rgb: 238, 238, 238; + --bs-secondary-color: #555555; + --bs-tertiary-bg: #ffffff; + --bs-border-color: #dddddd; + --bs-link-color: #337ab7; +} + +[data-bs-theme="dark"] { + --bs-body-bg: #1f252b; + --bs-body-color: #e7edf2; + --bs-primary: #5dade2; + --bs-primary-rgb: 93, 173, 226; + --bs-success: #70c770; + --bs-danger: #e26b67; + --bs-warning: #f4bd65; + --bs-info: #73cde6; + --bs-secondary-bg: #2b333b; + --bs-secondary-bg-rgb: 43, 51, 59; + --bs-secondary-color: #bac4cd; + --bs-tertiary-bg: #252c33; + --bs-border-color: #46515b; + --bs-link-color: #8cc8f0; +} + +/* Note: Bootstrap 3 panels and wells are represented through shared app containers. */ +.card, +.dropdown-menu, +.modal-content, +.surface-section, +.smart-setting-row, +.toast { + background-color: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 4px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); +} + +.navbar, +.topbar { + background-image: linear-gradient(#ffffff, #f2f2f2); + border-bottom: 1px solid var(--bs-border-color); +} + +[data-bs-theme="dark"] .navbar, +[data-bs-theme="dark"] .topbar { + background-image: linear-gradient(#303941, #20272e); +} + +.btn { + border-width: 1px; + border-radius: 4px; + font-weight: 600; + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease; +} + +.btn:hover { + filter: none; + text-decoration: none; +} + +.btn:focus-visible { + box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32); + filter: none; + text-decoration: none; +} + +.btn-primary { + background-image: linear-gradient(#428bca, #2d6ca2); + border-color: #2b669a; + color: #ffffff; +} + +.btn-primary:hover, +.btn-primary:focus-visible, +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + background-color: #2d6ca2; + color: #ffffff; +} + +.btn-success, +.btn-success:hover, +.btn-success:focus-visible { + background-color: #5cb85c; + border-color: #4cae4c; + color: #ffffff; +} + +.btn-danger, +.btn-danger:hover, +.btn-danger:focus-visible { + background-color: #d9534f; + border-color: #d43f3a; + color: #ffffff; +} + +.btn-warning, +.btn-warning:hover, +.btn-warning:focus-visible { + background-color: #f0ad4e; + border-color: #eea236; + color: #111111; +} + +.btn-info, +.btn-info:hover, +.btn-info:focus-visible { + background-color: #5bc0de; + border-color: #46b8da; + color: #ffffff; +} + +.btn-secondary, +.btn-outline-secondary, +.btn-light { + background-color: #ffffff; + border-color: #cccccc; + color: #333333; +} + +.btn-outline-primary, +.btn-outline-success, +.btn-outline-danger, +.btn-outline-warning, +.btn-outline-info { + background-color: var(--bs-body-bg); +} + +.btn-outline-primary:hover, +.btn-outline-primary:focus-visible { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: #ffffff; +} + +.btn-outline-success:hover, +.btn-outline-success:focus-visible { + background-color: var(--bs-success); + border-color: var(--bs-success); + color: #ffffff; +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background-color: var(--bs-danger); + border-color: var(--bs-danger); + color: #ffffff; +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus-visible { + background-color: var(--bs-warning); + border-color: var(--bs-warning); + color: #111111; +} + +.btn-outline-info:hover, +.btn-outline-info:focus-visible { + background-color: var(--bs-info); + border-color: var(--bs-info); + color: #111111; +} + +.btn-secondary:hover, +.btn-secondary:focus-visible, +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background-color: #e6e6e6; + border-color: #adadad; + color: #222222; +} + +[data-bs-theme="dark"] .btn-secondary, +[data-bs-theme="dark"] .btn-outline-secondary, +[data-bs-theme="dark"] .btn-light { + background-color: #323b44; + border-color: #53606b; + color: #edf2f6; +} + +[data-bs-theme="dark"] .btn-secondary:hover, +[data-bs-theme="dark"] .btn-secondary:focus-visible, +[data-bs-theme="dark"] .btn-outline-secondary:hover, +[data-bs-theme="dark"] .btn-outline-secondary:focus-visible { + background-color: #46515b; + color: #ffffff; +} + +.form-control, +.form-select, +.input-group-text { + border: 1px solid #cccccc; + border-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.form-control:focus, +.form-select:focus { + border-color: #66afe9; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6); +} + +.card-header, +.modal-header, +.table thead th { + background-color: #f5f5f5; + border-bottom: 1px solid var(--bs-border-color); + color: var(--bs-body-color); + font-weight: 700; +} + +[data-bs-theme="dark"] .card-header, +[data-bs-theme="dark"] .modal-header, +[data-bs-theme="dark"] .table thead th { + background-color: #303941; +} + +.badge, +.label { + border-radius: 4px; +} + +.alert { + border-radius: 4px; + border-width: 1px; +} + +.progress { + background-color: #f5f5f5; + border-radius: 4px; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.progress-bar { + background-image: linear-gradient(#5bc0de, #339bb9); +} + +.table > :not(caption) > * > * { + border-bottom-color: var(--bs-border-color); +} diff --git a/pytorrent/static/libs/pytorrent-themes/bootstrap4/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/bootstrap4/bootstrap.min.css new file mode 100644 index 0000000..826e2b1 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/bootstrap4/bootstrap.min.css @@ -0,0 +1,142 @@ +/* Note: Bootstrap 4 Cards uses the v4 blue, subtler radii and card-first surfaces. */ +:root { + --bs-border-radius: .25rem; + --bs-border-radius-sm: .2rem; + --bs-border-radius-lg: .3rem; + --bs-font-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +[data-bs-theme="light"] { + --bs-body-bg: #f8f9fa; + --bs-body-color: #212529; + --bs-primary: #007bff; + --bs-primary-rgb: 0, 123, 255; + --bs-success: #28a745; + --bs-danger: #dc3545; + --bs-warning: #ffc107; + --bs-info: #17a2b8; + --bs-secondary-bg: #e9ecef; + --bs-secondary-bg-rgb: 233, 236, 239; + --bs-secondary-color: #6c757d; + --bs-tertiary-bg: #ffffff; + --bs-border-color: #dee2e6; + --bs-link-color: #007bff; +} + +[data-bs-theme="dark"] { + --bs-body-bg: #181c20; + --bs-body-color: #e9ecef; + --bs-primary: #4dabf7; + --bs-primary-rgb: 77, 171, 247; + --bs-success: #51cf66; + --bs-danger: #ff6b6b; + --bs-warning: #ffd43b; + --bs-info: #3bc9db; + --bs-secondary-bg: #2a3036; + --bs-secondary-bg-rgb: 42, 48, 54; + --bs-secondary-color: #c1c7cd; + --bs-tertiary-bg: #22272c; + --bs-border-color: #444c55; + --bs-link-color: #74c0fc; +} + +.card, +.surface-section, +.modal-content, +.dropdown-menu { + border-radius: .25rem; + box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .075); +} + +.btn-primary:hover, +.btn-primary:focus-visible { + background-color: #0069d9; + border-color: #0062cc; +} + +[data-bs-theme="dark"] .btn-primary:hover, +[data-bs-theme="dark"] .btn-primary:focus-visible { + background-color: #228be6; + border-color: #1c7ed6; +} + +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + background-color: var(--bs-primary); + color: #ffffff; +} + +.btn { + border-width: 1px; + font-weight: 600; + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease, filter .15s ease; +} + +.btn:hover, +.btn:focus-visible { + filter: none; + text-decoration: none; +} + +.btn-primary, +.btn-success, +.btn-danger, +.btn-warning, +.btn-info { + color: #ffffff; +} + +.btn-outline-primary, +.btn-outline-success, +.btn-outline-danger, +.btn-outline-warning, +.btn-outline-info, +.btn-outline-secondary { + background-color: var(--bs-body-bg); +} + +.btn-outline-primary:hover, +.btn-outline-primary:focus-visible { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: #ffffff; +} + +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background-color: var(--bs-secondary-color); + border-color: var(--bs-secondary-color); + color: var(--bs-body-bg); +} + +.btn-outline-success:hover, +.btn-outline-success:focus-visible { + background-color: var(--bs-success); + border-color: var(--bs-success); + color: #ffffff; +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background-color: var(--bs-danger); + border-color: var(--bs-danger); + color: #ffffff; +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus-visible { + background-color: var(--bs-warning); + border-color: var(--bs-warning); + color: #111111; +} + +.btn-outline-info:hover, +.btn-outline-info:focus-visible { + background-color: var(--bs-info); + border-color: var(--bs-info); + color: #111111; +} + +.btn:focus-visible { + box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32); +} diff --git a/pytorrent/static/libs/pytorrent-themes/bootstrap5-soft/bootstrap.min.css b/pytorrent/static/libs/pytorrent-themes/bootstrap5-soft/bootstrap.min.css new file mode 100644 index 0000000..bebe9a0 --- /dev/null +++ b/pytorrent/static/libs/pytorrent-themes/bootstrap5-soft/bootstrap.min.css @@ -0,0 +1,146 @@ +/* Note: Bootstrap 5 Soft keeps the modern variable palette with stronger action hover contrast. */ +:root { + --bs-border-radius: .5rem; + --bs-border-radius-sm: .375rem; + --bs-border-radius-lg: .75rem; + --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +[data-bs-theme="light"] { + --bs-body-bg: #f6f8fb; + --bs-body-color: #1f2937; + --bs-primary: #0d6efd; + --bs-primary-rgb: 13, 110, 253; + --bs-success: #198754; + --bs-danger: #dc3545; + --bs-warning: #ffc107; + --bs-info: #0dcaf0; + --bs-secondary-bg: #edf2f7; + --bs-secondary-bg-rgb: 237, 242, 247; + --bs-secondary-color: #64748b; + --bs-tertiary-bg: #ffffff; + --bs-border-color: #d9e2ec; + --bs-link-color: #0d6efd; +} + +[data-bs-theme="dark"] { + --bs-body-bg: #101827; + --bs-body-color: #e5e7eb; + --bs-primary: #60a5fa; + --bs-primary-rgb: 96, 165, 250; + --bs-success: #34d399; + --bs-danger: #f87171; + --bs-warning: #fbbf24; + --bs-info: #22d3ee; + --bs-secondary-bg: #1e293b; + --bs-secondary-bg-rgb: 30, 41, 59; + --bs-secondary-color: #cbd5e1; + --bs-tertiary-bg: #172033; + --bs-border-color: #334155; + --bs-link-color: #93c5fd; +} + +.card, +.surface-section, +.modal-content, +.dropdown-menu { + border-radius: .75rem; + box-shadow: 0 .35rem 1rem rgba(15, 23, 42, .08); +} + +.btn { + border-radius: .5rem; +} + +.btn-primary:hover, +.btn-primary:focus-visible { + background-color: #0b5ed7; + border-color: #0a58ca; +} + +[data-bs-theme="dark"] .btn-primary:hover, +[data-bs-theme="dark"] .btn-primary:focus-visible { + background-color: #3b82f6; + border-color: #2563eb; +} + +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + background-color: var(--bs-primary); + color: #ffffff; +} + +.btn { + border-width: 1px; + font-weight: 600; + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease, filter .15s ease; +} + +.btn:hover, +.btn:focus-visible { + filter: none; + text-decoration: none; +} + +.btn-primary, +.btn-success, +.btn-danger, +.btn-warning, +.btn-info { + color: #ffffff; +} + +.btn-outline-primary, +.btn-outline-success, +.btn-outline-danger, +.btn-outline-warning, +.btn-outline-info, +.btn-outline-secondary { + background-color: var(--bs-body-bg); +} + +.btn-outline-primary:hover, +.btn-outline-primary:focus-visible { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: #ffffff; +} + +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background-color: var(--bs-secondary-color); + border-color: var(--bs-secondary-color); + color: var(--bs-body-bg); +} + +.btn-outline-success:hover, +.btn-outline-success:focus-visible { + background-color: var(--bs-success); + border-color: var(--bs-success); + color: #ffffff; +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background-color: var(--bs-danger); + border-color: var(--bs-danger); + color: #ffffff; +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus-visible { + background-color: var(--bs-warning); + border-color: var(--bs-warning); + color: #111111; +} + +.btn-outline-info:hover, +.btn-outline-info:focus-visible { + background-color: var(--bs-info); + border-color: var(--bs-info); + color: #111111; +} + +.btn:focus-visible { + box-shadow: 0 0 0 .2rem rgba(var(--bs-primary-rgb), .32); +} diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 2cbebc9..1986542 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -840,12 +840,15 @@ body.resizing-details { } .toast-count { - flex: 0 0 auto; - padding: 0.05rem 0.35rem; - border-radius: 999px; + align-items: center; background: rgba(255, 255, 255, 0.22); + border-radius: 999px; + display: inline-flex; + flex: 0 0 auto; font-size: 0.78rem; font-weight: 700; + gap: 0.1rem; + padding: 0.05rem 0.35rem; } @media (max-width: 1100px) { :root { @@ -3077,6 +3080,43 @@ body.mobile-mode .mobile-filter-bar { margin-bottom: 0.7rem; } +.planner-current-summary ul { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1rem; + margin-bottom: 0; +} + +.planner-disclosure > summary { + cursor: pointer; + justify-content: space-between; + list-style: none; + margin-bottom: 0; +} + +.planner-disclosure > summary::-webkit-details-marker { + display: none; +} + +.planner-disclosure[open] > summary { + margin-bottom: 0.7rem; +} + +.planner-card-title span { + align-items: center; + display: inline-flex; + gap: 0.45rem; +} + +.planner-card-chevron { + color: var(--bs-secondary-color); + transition: transform 0.15s ease; +} + +.planner-disclosure[open] .planner-card-chevron { + transform: rotate(180deg); +} + .planner-card-time, .planner-card-protection { display: grid; @@ -3198,9 +3238,6 @@ body.mobile-mode .mobile-filter-bar { .planner-hour-row small { color: var(--bs-secondary-color); -} - -.planner-hour-row small { white-space: nowrap; } @@ -3444,7 +3481,16 @@ body.mobile-mode .mobile-filter-bar { .profile-diagnostics-card{border:1px solid var(--bs-border-color);border-radius:.5rem;padding:.5rem;background:var(--bs-body-bg);} .profile-diagnostics-card small{display:block;color:var(--bs-secondary-color);} -.labels-manager { display: grid; gap: 0.5rem; } +.labels-manager { + display: grid; + gap: 0.5rem; +} + +.labels-manager .empty-state { + align-items: flex-start; + justify-self: stretch; + text-align: left; +} /* UI hygiene: keep long status/footer content inside the app instead of widening the browser viewport. */ html, @@ -4311,6 +4357,11 @@ body, /* Operation logs */ +.logs-tools-layout { + display: grid; + gap: 0.85rem; +} + .operation-log-toolbar, .operation-log-toolbar-main, .operation-log-settings-grid, @@ -4336,9 +4387,6 @@ body, .operation-log-view-settings { align-items: center; - border-top: 1px solid var(--bs-border-color); - margin-top: 1rem; - padding-top: 1rem; } .operation-log-view-settings > div:first-child { @@ -4437,25 +4485,159 @@ body, } } +/* Note: Peers tables keep hostnames readable without letting the Host column dominate the layout. */ .peers-table { - table-layout: auto; + min-width: 960px; + table-layout: fixed; width: 100%; } +.peers-table th, +.peers-table td { + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + .peers-table .peer-progress-wide { min-width: 108px; - width: clamp(108px, 12vw, 126px); + width: 100%; +} + +.peers-table-hosts th:nth-child(1), +.peers-table-hosts td:nth-child(1) { + width: 4%; +} + +.peers-table-hosts th:nth-child(2), +.peers-table-hosts td:nth-child(2) { + width: 13%; +} + +.peers-table-hosts th:nth-child(3), +.peers-table-hosts td:nth-child(3) { + width: 15%; +} + +.peers-table-hosts th:nth-child(4), +.peers-table-hosts td:nth-child(4), +.peers-table-hosts th:nth-child(5), +.peers-table-hosts td:nth-child(5) { + width: 8%; +} + +.peers-table-hosts th:nth-child(6), +.peers-table-hosts td:nth-child(6) { + width: 15%; +} + +.peers-table-hosts th:nth-child(7), +.peers-table-hosts td:nth-child(7) { + width: 10%; +} + +.peers-table-hosts th:nth-child(8), +.peers-table-hosts td:nth-child(8), +.peers-table-hosts th:nth-child(9), +.peers-table-hosts td:nth-child(9) { + width: 6%; +} + +.peers-table-hosts th:nth-child(10), +.peers-table-hosts td:nth-child(10) { + width: 5%; +} + +.peers-table-hosts th:nth-child(11), +.peers-table-hosts td:nth-child(11) { + width: 10%; } .peer-host { - display: inline-block; - max-width: 220px; + display: block; + max-width: 100%; + min-width: 0; overflow: hidden; text-overflow: ellipsis; - vertical-align: middle; white-space: nowrap; } +/* Note: Mobile torrent details use a narrower fixed table so long reverse-DNS names cannot stretch the modal. */ +.mobile-details-modal .modal-body { + overflow-x: hidden; +} + +.mobile-details-modal .responsive-table-wrap { + max-width: 100%; +} + +.mobile-details-peers-table { + min-width: 720px; +} + +.mobile-details-peers-table.peers-table-hosts th:nth-child(1), +.mobile-details-peers-table.peers-table-hosts td:nth-child(1) { + width: 5%; +} + +.mobile-details-peers-table.peers-table-hosts th:nth-child(2), +.mobile-details-peers-table.peers-table-hosts td:nth-child(2) { + width: 14%; +} + +.mobile-details-peers-table.peers-table-hosts th:nth-child(3), +.mobile-details-peers-table.peers-table-hosts td:nth-child(3), +.mobile-details-peers-table.peers-table-hosts th:nth-child(4), +.mobile-details-peers-table.peers-table-hosts td:nth-child(4) { + width: 15%; +} + +.mobile-details-peers-table.peers-table-hosts th:nth-child(5), +.mobile-details-peers-table.peers-table-hosts td:nth-child(5) { + width: 16%; +} + +.mobile-details-peers-table.peers-table-hosts th:nth-child(6), +.mobile-details-peers-table.peers-table-hosts td:nth-child(6) { + width: 10%; +} + +.mobile-details-peers-table.peers-table-hosts th:nth-child(7), +.mobile-details-peers-table.peers-table-hosts td:nth-child(7), +.mobile-details-peers-table.peers-table-hosts th:nth-child(8), +.mobile-details-peers-table.peers-table-hosts td:nth-child(8) { + width: 7%; +} + +.mobile-details-peers-table.peers-table-hosts th:nth-child(9), +.mobile-details-peers-table.peers-table-hosts td:nth-child(9), +.mobile-details-peers-table.peers-table-hosts th:nth-child(10), +.mobile-details-peers-table.peers-table-hosts td:nth-child(10) { + width: 6%; +} + + +/* App modal widths stay consistent while Bootstrap still handles full-screen mobile breakpoints. */ +.app-modal-dialog, +.modal-dialog.modal-xl { + --bs-modal-width: min(1140px, calc(100vw - 2rem)); +} + +@media (max-width: 575.98px) { + .app-modal-dialog { + --bs-modal-width: 100vw; + } +} + +/* Current rTorrent settings uses the same card rhythm as Diagnostics for faster scanning. */ +.rt-config-current-summary { + margin-bottom: 1rem; +} + +.rt-config-value-note { + word-break: break-word; +} + .file-row-actions { align-items: center; display: inline-flex; @@ -4912,10 +5094,6 @@ body, margin-bottom: 0; } -.mobile-details-peers-table { - min-width: 720px; -} - .mobile-details-files-table { min-width: 760px; } @@ -5036,3 +5214,63 @@ body.compact-torrent-list .mobile-progress .torrent-progress { opacity: 0.85; pointer-events: none; } + + +/* Note: Mobile sort uses Font Awesome icons instead of text arrows to avoid broken glyph spacing. */ +.mobile-sort-cycle-label { + align-items: center; + display: inline-flex; + gap: 0.45rem; + justify-content: center; + min-width: 0; +} + +.mobile-sort-cycle-label i { + flex: 0 0 auto; + line-height: 1; + width: 1rem; +} + +/* Note: Planner Current Settings inherits Poller Diagnostics card chrome from .smart-setting-row. */ +.planner-current-summary { + align-items: flex-start; +} + +.planner-diagnostic-line { + align-items: center; + color: var(--bs-secondary-color); + display: flex; + flex-wrap: wrap; + gap: 0.3rem 0.55rem; + line-height: 1.45; + margin-top: 0.2rem; +} + +/* Note: Keep each Current Settings entry on a single visual line so labels do not break above values. */ +.planner-diagnostic-item { + align-items: baseline; + display: inline-flex; + gap: 0.25rem; + white-space: nowrap; +} + +.planner-diagnostic-item b { + color: var(--bs-body-color); + display: inline; + font-weight: 700; +} + + +/* Note: Add breathing room around Current Settings separators to match Poller Diagnostics readability. */ +.planner-diagnostic-line .diagnostic-separator { + margin: 0 0.18rem; +} + +.diagnostic-separator, +.modal-meta-separator { + color: var(--bs-secondary-color); + font-size: 0.38rem; + opacity: 0.8; + vertical-align: middle; +} + diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index e019024..1903147 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -277,10 +277,8 @@
    Appearance
    Theme, typography and interface scale. Torrent view preferences also remember the selected filter, sorting and the height of the General / Files / Trackers panel.
    View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.
    Browser title
    Controls what is shown in the browser tab.
    Tracker icons
    Visual helper for tracker filters in the sidebar.
    Notifications
    Toast notifications from automatic systems.
    Disk monitor
    Choose what the footer disk bar should represent and add extra storage paths.
    Progress source
    Monitored paths
    The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.
    Port checker
    Incoming connection test, separate from visual preferences.
    disabled
    Uses YouGetSignal first. Manual check bypasses the 6h cache.
    Peers
    Optional peer table helpers.
    Job scheduling
    These settings are stored per active rTorrent profile. Light jobs are control actions such as start, stop, pause, resume, labels, ratio assignment, reannounce and speed limits. Heavy jobs are long or destructive actions such as move, remove and adding torrents.
    -
    Operation log retention
    Manage operation log retention and review profile-scoped statistics without changing torrent data.
    Default log viewControls the default category and job log visibility used by the Logs modal.
    Loading statistics...
    -{% if auth_enabled and current_user and current_user.role == 'admin' %} +
    Operation log retention
    Manage operation log retention without changing torrent data.
    Default log view
    Controls the default category and job log visibility used by the Logs modal.
    Log statistics
    Profile-scoped log counts and cleanup overview.
    Loading statistics...
    Users
    Manage optional pyTorrent users. Empty profile means all profiles. R/O blocks rTorrent-changing actions; Full allows them.
    -{% endif %}
    Labels
    Create reusable labels and remove labels that are no longer needed.
    @@ -311,7 +309,7 @@
    Automations / rules
    Build a rule as: conditions first, then ordered actions. Matching torrents are handled as one batch and the cooldown is applied to the whole rule.
    1. Rule
    2. Conditions
    3. Actions, in order
    Rules
    History
    rTorrent config
    Typical rTorrent options, like in ruTorrent. Unsupported methods are shown as unavailable.
    Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.
    No changes
    Loading config...
    Cleanup / retention
    One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.
    Loading cleanup data...
    -
    Backup / restore
    Backup contains persistent settings: users, permissions, preferences, profiles, labels, ratio groups, RSS, Smart Queue, Automations, Planner and rTorrent config overrides.
    +
    Backup / restore
    Profile backup restores only the active profile context. Application backup restores global application data and is available only to admins.
    Creates and restores settings for the currently selected profile. User-scoped preferences are remapped to the current user where needed.
    Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.
    pyTorrent status
    Diagnostics for pyTorrent process and active SCGI/XMLRPC connection.
    Open this tab to load diagnostics.
    diff --git a/scripts/db_cleanup.py b/scripts/db_cleanup.py new file mode 100644 index 0000000..6eabf3d --- /dev/null +++ b/scripts/db_cleanup.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +import shutil +import sqlite3 +from datetime import datetime +from pathlib import Path + +DB_PATH = Path("/opt/pyTorrent/data/pytorrent.sqlite3") + +DROP_COLUMNS = { + "rss_feeds": [ + "user_id", + ], + "rss_rules": [ + "user_id", + ], + "rss_history": [ + "user_id", + ], + "smart_queue_settings": [ + "user_id", + ], + "smart_queue_exclusions": [ + "user_id", + ], + "smart_queue_history": [ + "user_id", + ], + "rtorrent_config_overrides": [ + "user_id", + ], + "user_preferences": [ + "table_columns_json", + "peers_refresh_seconds", + "port_check_enabled", + "tracker_favicons_enabled", + "reverse_dns_enabled", + "disk_monitor_paths_json", + "disk_monitor_mode", + "disk_monitor_selected_path", + "disk_monitor_stop_enabled", + "disk_monitor_stop_threshold", + "torrent_sort_json", + "active_filter", + ], +} + +DROP_INDEXES = [ + "idx_rss_feeds_user_profile", + "idx_rss_rules_user_profile", + "idx_rss_history_user_profile", + "idx_smart_queue_settings_user_profile", + "idx_smart_queue_exclusions_user_profile", + "idx_smart_queue_history_user_profile", + "idx_rtorrent_config_overrides_user_profile", +] + +EXPECTED_PROFILE_TABLES = { + "rss_feeds": ["profile_id"], + "rss_rules": ["profile_id"], + "rss_history": ["profile_id"], + "smart_queue_settings": ["profile_id"], + "smart_queue_exclusions": ["profile_id"], + "smart_queue_history": ["profile_id"], + "rtorrent_config_overrides": ["profile_id"], +} + + +def table_exists(conn: sqlite3.Connection, table: str) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ).fetchone() + return row is not None + + +def index_exists(conn: sqlite3.Connection, index: str) -> bool: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='index' AND name=?", + (index,), + ).fetchone() + return row is not None + + +def columns(conn: sqlite3.Connection, table: str) -> list[str]: + return [row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')] + + +def quote_ident(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def validate_profile_tables(conn: sqlite3.Connection) -> None: + print("Checking required profile scoped tables...") + + for table, required_columns in EXPECTED_PROFILE_TABLES.items(): + if not table_exists(conn, table): + print(f"SKIP table missing: {table}") + continue + + table_columns = columns(conn, table) + + for column in required_columns: + if column not in table_columns: + raise RuntimeError( + f"Unsafe cleanup: table {table} does not contain required column {column}" + ) + + print(f"OK {table}: has {', '.join(required_columns)}") + + +def drop_indexes(conn: sqlite3.Connection) -> None: + print("\nDropping obsolete indexes if present...") + + for index in DROP_INDEXES: + if not index_exists(conn, index): + print(f"SKIP index missing: {index}") + continue + + conn.execute(f"DROP INDEX {quote_ident(index)}") + print(f"DROPPED index: {index}") + + +def drop_obsolete_columns(conn: sqlite3.Connection) -> None: + print("\nDropping obsolete columns if present...") + + for table, obsolete_columns in DROP_COLUMNS.items(): + if not table_exists(conn, table): + print(f"SKIP table missing: {table}") + continue + + current_columns = columns(conn, table) + + for column in obsolete_columns: + if column not in current_columns: + print(f"SKIP column missing: {table}.{column}") + continue + + try: + conn.execute( + f"ALTER TABLE {quote_ident(table)} DROP COLUMN {quote_ident(column)}" + ) + print(f"DROPPED column: {table}.{column}") + current_columns.remove(column) + except sqlite3.OperationalError as exc: + print(f"FAILED column: {table}.{column} -> {exc}") + print("This usually means the column is used by an index, constraint, or old SQLite version.") + + +def vacuum(conn: sqlite3.Connection) -> None: + print("\nRunning VACUUM...") + conn.execute("VACUUM") + print("VACUUM done.") + + +def main() -> None: + if not DB_PATH.exists(): + raise SystemExit(f"Database not found: {DB_PATH}") + + backup_path = DB_PATH.with_suffix( + DB_PATH.suffix + f".cleanup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.bak" + ) + + print(f"Database: {DB_PATH}") + print(f"Backup: {backup_path}") + + shutil.copy2(DB_PATH, backup_path) + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + + try: + conn.execute("PRAGMA foreign_keys = OFF") + + validate_profile_tables(conn) + + conn.execute("BEGIN") + drop_indexes(conn) + drop_obsolete_columns(conn) + conn.commit() + + conn.execute("PRAGMA foreign_keys = ON") + + vacuum(conn) + + print("\nCleanup completed successfully.") + print(f"Backup saved as: {backup_path}") + + except Exception: + conn.rollback() + print("\nCleanup failed. Database rollback completed.") + print(f"Backup is available at: {backup_path}") + raise + + finally: + conn.close() + + +if __name__ == "__main__": + main()