diff --git a/.gitignore b/.gitignore
index af84d72..435d886 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,8 +40,9 @@ data/logs/*
!data/logs/
!data/logs/README.md
-
todo.txt
!pytorrent/static/libs/pytorrent-themes/
!pytorrent/static/libs/pytorrent-themes/**
+*/static/libs/
smart_queue_scoring_todo.md
+data/mock_rtorrent_state.json
\ No newline at end of file
diff --git a/pytorrent/config.py b/pytorrent/config.py
index 74104aa..45810c0 100644
--- a/pytorrent/config.py
+++ b/pytorrent/config.py
@@ -108,5 +108,5 @@ LOG_ENABLE = _env_bool("PYTORRENT_LOG_ENABLE", True)
LOG_DIR = Path(os.getenv("PYTORRENT_LOG_DIR", "data/logs"))
if not LOG_DIR.is_absolute():
LOG_DIR = BASE_DIR / LOG_DIR
-SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped")
+SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", os.getenv("PYTORRENT_SMART_QUEUE_L.ABEL", "Smart Queue Stopped"))
SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled")
diff --git a/pytorrent/db.py b/pytorrent/db.py
index f031c00..f27a89e 100644
--- a/pytorrent/db.py
+++ b/pytorrent/db.py
@@ -4,6 +4,7 @@ import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from .config import DB_PATH
+from .migrations import run_database_migrations
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
@@ -84,6 +85,8 @@ CREATE TABLE IF NOT EXISTS profile_preferences (
port_check_enabled INTEGER DEFAULT 0,
tracker_favicons_enabled INTEGER DEFAULT 0,
reverse_dns_enabled INTEGER DEFAULT 0,
+ sidebar_labels_expanded INTEGER DEFAULT 0,
+ sidebar_shortcuts_expanded INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id),
@@ -527,59 +530,10 @@ CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
def create_schema(conn: sqlite3.Connection) -> None:
- """Create the current database schema without running legacy migrations."""
+ """Create the current database schema definition."""
conn.executescript(SCHEMA)
-def ensure_profile_scoped_disk_monitor_preferences(conn: sqlite3.Connection) -> None:
- """Migrate disk monitor settings from user+profile rows to one shared row per profile."""
- columns = conn.execute("PRAGMA table_info(disk_monitor_preferences)").fetchall()
- pk_columns = [str(row["name"]) for row in columns if int(row.get("pk") or 0)]
- if pk_columns == ["profile_id"]:
- conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
- return
-
- now = utcnow()
- conn.execute("DROP INDEX IF EXISTS idx_disk_monitor_preferences_owner")
- conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_new")
- conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_old_user_profile")
- conn.execute("""
- CREATE TABLE disk_monitor_preferences_new (
- profile_id INTEGER PRIMARY KEY,
- user_id INTEGER NOT NULL,
- paths_json TEXT,
- mode TEXT DEFAULT 'default',
- selected_path TEXT,
- stop_enabled INTEGER DEFAULT 0,
- stop_threshold INTEGER DEFAULT 98,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- FOREIGN KEY(user_id) REFERENCES users(id),
- FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
- )
- """)
- conn.execute("""
- INSERT INTO disk_monitor_preferences_new(
- profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at
- )
- SELECT profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,
- COALESCE(created_at, ?), COALESCE(updated_at, ?)
- FROM (
- SELECT d.*,
- ROW_NUMBER() OVER (
- PARTITION BY profile_id
- ORDER BY COALESCE(updated_at, created_at, '') DESC, user_id ASC
- ) AS rn
- FROM disk_monitor_preferences d
- WHERE profile_id IS NOT NULL
- )
- WHERE rn=1
- """, (now, now))
- conn.execute("ALTER TABLE disk_monitor_preferences RENAME TO disk_monitor_preferences_old_user_profile")
- conn.execute("ALTER TABLE disk_monitor_preferences_new RENAME TO disk_monitor_preferences")
- conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
-
-
def seed_default_user(conn: sqlite3.Connection) -> None:
"""Ensure the built-in admin user and default preferences exist."""
now = utcnow()
@@ -623,17 +577,14 @@ def connect():
def init_db():
- """Initialize SQLite using the current schema only.
-
- Note: migration execution is intentionally not part of this flow.
- """
+ """Initialize SQLite, applying the current schema and idempotent migrations."""
with connect() as conn:
try:
conn.execute("PRAGMA journal_mode = WAL")
except sqlite3.OperationalError:
pass
create_schema(conn)
- ensure_profile_scoped_disk_monitor_preferences(conn)
+ run_database_migrations(conn)
seed_default_user(conn)
try:
from .services.auth import ensure_admin_user
diff --git a/pytorrent/migrations.py b/pytorrent/migrations.py
index 2fc0e33..0a0a57a 100644
--- a/pytorrent/migrations.py
+++ b/pytorrent/migrations.py
@@ -1,15 +1,110 @@
from __future__ import annotations
import sqlite3
+from collections.abc import Callable
+from datetime import datetime, timezone
-MIGRATIONS: tuple[str, ...] = ()
+
+Migration = Callable[[sqlite3.Connection], bool]
+
+
+def _utcnow() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def _row_value(row: sqlite3.Row | dict[str, object] | tuple[object, ...], key: str, index: int) -> object:
+ try:
+ return row[key] # type: ignore[index]
+ except (KeyError, IndexError, TypeError):
+ return row[index] # type: ignore[index]
+
+
+def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
+ return {str(_row_value(row, "name", 1)) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
+
+
+def _primary_key_columns(conn: sqlite3.Connection, table: str) -> list[str]:
+ columns = conn.execute(f"PRAGMA table_info({table})").fetchall()
+ pk_columns = sorted(
+ (
+ (int(_row_value(row, "pk", 5) or 0), str(_row_value(row, "name", 1)))
+ for row in columns
+ if int(_row_value(row, "pk", 5) or 0)
+ ),
+ key=lambda item: item[0],
+ )
+ return [name for _, name in pk_columns]
+
+
+def migrate_disk_monitor_preferences_to_profile_scope(conn: sqlite3.Connection) -> bool:
+ if _primary_key_columns(conn, "disk_monitor_preferences") == ["profile_id"]:
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
+ return False
+
+ now = _utcnow()
+ conn.execute("DROP INDEX IF EXISTS idx_disk_monitor_preferences_owner")
+ conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_new")
+ conn.execute("DROP TABLE IF EXISTS disk_monitor_preferences_old_user_profile")
+ conn.execute("""
+ CREATE TABLE disk_monitor_preferences_new (
+ profile_id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ paths_json TEXT,
+ mode TEXT DEFAULT 'default',
+ selected_path TEXT,
+ stop_enabled INTEGER DEFAULT 0,
+ stop_threshold INTEGER DEFAULT 98,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(id),
+ FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id)
+ )
+ """)
+ conn.execute("""
+ INSERT INTO disk_monitor_preferences_new(
+ profile_id, user_id, paths_json, mode, selected_path, stop_enabled, stop_threshold, created_at, updated_at
+ )
+ SELECT profile_id, user_id, paths_json, mode, selected_path, stop_enabled, stop_threshold,
+ COALESCE(created_at, ?), COALESCE(updated_at, ?)
+ FROM (
+ SELECT d.*,
+ ROW_NUMBER() OVER (
+ PARTITION BY profile_id
+ ORDER BY COALESCE(updated_at, created_at, '') DESC, user_id ASC
+ ) AS rn
+ FROM disk_monitor_preferences d
+ WHERE profile_id IS NOT NULL
+ )
+ WHERE rn = 1
+ """, (now, now))
+ conn.execute("ALTER TABLE disk_monitor_preferences RENAME TO disk_monitor_preferences_old_user_profile")
+ conn.execute("ALTER TABLE disk_monitor_preferences_new RENAME TO disk_monitor_preferences")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id)")
+ return True
+
+
+def migrate_profile_preferences_sidebar_columns(conn: sqlite3.Connection) -> bool:
+ columns = _column_names(conn, "profile_preferences")
+ changed = False
+ if "sidebar_labels_expanded" not in columns:
+ conn.execute("ALTER TABLE profile_preferences ADD COLUMN sidebar_labels_expanded INTEGER DEFAULT 0")
+ changed = True
+ if "sidebar_shortcuts_expanded" not in columns:
+ conn.execute("ALTER TABLE profile_preferences ADD COLUMN sidebar_shortcuts_expanded INTEGER DEFAULT 0")
+ changed = True
+ return changed
+
+
+MIGRATIONS: tuple[Migration, ...] = (
+ migrate_disk_monitor_preferences_to_profile_scope,
+ migrate_profile_preferences_sidebar_columns,
+)
def run_database_migrations(conn: sqlite3.Connection) -> int:
- """Run pending database migrations."""
-
+ """Run idempotent database migrations and return how many changed the schema/data."""
applied = 0
- for sql in MIGRATIONS:
- conn.execute(sql)
- applied += 1
+ for migration in MIGRATIONS:
+ if migration(conn):
+ applied += 1
return applied
diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json
index b69e485..93e344a 100644
--- a/pytorrent/openapi/openapi.json
+++ b/pytorrent/openapi/openapi.json
@@ -1178,6 +1178,14 @@
},
"tracker_favicons_enabled": {
"type": "boolean"
+ },
+ "sidebar_labels_expanded": {
+ "description": "Stores whether the sidebar label group is expanded for the active profile.",
+ "type": "boolean"
+ },
+ "sidebar_shortcuts_expanded": {
+ "description": "Stores whether the sidebar keyboard shortcut help is expanded for the active profile.",
+ "type": "boolean"
}
},
"type": "object"
diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py
index a2b694e..467e216 100644
--- a/pytorrent/routes/main.py
+++ b/pytorrent/routes/main.py
@@ -10,7 +10,7 @@ import zipfile
from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file, stream_with_context
from ..services.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES
from ..services import auth, pdf_preview_links, rtorrent
-from ..config import PYTORRENT_TMP_DIR
+from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL
from ..services.frontend_assets import asset_path
# for favicon
@@ -218,6 +218,8 @@ def index():
auth_provider=auth.provider(),
external_auth=auth.uses_external_provider(),
current_user=auth.current_user(),
+ smart_queue_label=SMART_QUEUE_LABEL,
+ smart_queue_stalled_label=SMART_QUEUE_STALLED_LABEL,
)
diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py
index 151acce..e980df5 100644
--- a/pytorrent/services/preferences.py
+++ b/pytorrent/services/preferences.py
@@ -377,7 +377,7 @@ def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
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(?,?,?,?,?,?,?,?,?,?,?)",
+ "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,sidebar_labels_expanded,sidebar_shortcuts_expanded,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
user_id,
profile_id,
@@ -388,6 +388,8 @@ def _seed_profile_preferences(conn, user_id: int, profile_id: int) -> dict:
int(legacy.get("port_check_enabled") or 0),
int(legacy.get("tracker_favicons_enabled") or 0),
int(legacy.get("reverse_dns_enabled") or 0),
+ int(legacy.get("sidebar_labels_expanded") or 0),
+ int(legacy.get("sidebar_shortcuts_expanded") or 0),
now,
now,
),
@@ -422,6 +424,12 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
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("sidebar_labels_expanded") is not None:
+ # Note: Label collapse state is per profile because each rTorrent can have a very different label set.
+ updates["sidebar_labels_expanded"] = 1 if data.get("sidebar_labels_expanded") else 0
+ if data.get("sidebar_shortcuts_expanded") is not None:
+ # Note: Shortcut help visibility is stored with profile preferences to survive refreshes.
+ updates["sidebar_shortcuts_expanded"] = 1 if data.get("sidebar_shortcuts_expanded") 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 "{}")
@@ -440,7 +448,7 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
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"}
+ allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "post_check", "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
@@ -448,8 +456,8 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
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",
+ "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,sidebar_labels_expanded,sidebar_shortcuts_expanded,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, sidebar_labels_expanded=excluded.sidebar_labels_expanded, sidebar_shortcuts_expanded=excluded.sidebar_shortcuts_expanded, updated_at=excluded.updated_at",
(
user_id,
profile_id,
@@ -460,6 +468,8 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
int(merged.get("port_check_enabled") or 0),
int(merged.get("tracker_favicons_enabled") or 0),
int(merged.get("reverse_dns_enabled") or 0),
+ int(merged.get("sidebar_labels_expanded") or 0),
+ int(merged.get("sidebar_shortcuts_expanded") or 0),
merged.get("created_at") or now,
now,
),
diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py
index ad7d137..d221554 100644
--- a/pytorrent/services/smart_queue.py
+++ b/pytorrent/services/smart_queue.py
@@ -391,9 +391,8 @@ def _smart_queue_label_cleanup_value(live_label: str | None, previous_label: str
def _has_stalled_label(value: str | None) -> bool:
- # Note: Stalled is treated case-insensitively so manually edited labels still block Smart Queue.
- target = SMART_QUEUE_STALLED_LABEL.casefold()
- return any(label.casefold() == target for label in _label_names(value))
+ # Note: Stalled is an exact technical label; lower-case variants are normal user labels.
+ return SMART_QUEUE_STALLED_LABEL in _label_names(value)
def _without_queue_technical_labels(value: str | None) -> str:
@@ -403,7 +402,7 @@ def _without_queue_technical_labels(value: str | None) -> str:
def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
labels = [label for label in _label_names(current_label) if label != SMART_QUEUE_LABEL]
changed = False
- if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels):
+ if SMART_QUEUE_STALLED_LABEL not in labels:
labels.append(SMART_QUEUE_STALLED_LABEL)
changed = True
if SMART_QUEUE_LABEL in _label_names(current_label):
@@ -421,13 +420,13 @@ def _ensure_stalled_label(client: Any, torrent_hash: str, current_label: str = '
def _without_stalled_label(value: str | None) -> str:
"""Return labels without Smart Queue's Stalled marker."""
# Note: This keeps user labels intact while clearing only the automatic stalled state.
- return _label_value([label for label in _label_names(value) if label.casefold() != SMART_QUEUE_STALLED_LABEL.casefold()])
+ return _label_value([label for label in _label_names(value) if label != SMART_QUEUE_STALLED_LABEL])
def _clear_stalled_label(client: Any, torrent_hash: str, current_label: str = '') -> bool:
"""Remove the Stalled marker from a torrent that is active again."""
labels = _label_names(current_label)
- if not any(label.casefold() == SMART_QUEUE_STALLED_LABEL.casefold() for label in labels):
+ if SMART_QUEUE_STALLED_LABEL not in labels:
return False
try:
# Note: Active downloads must not keep the Stalled marker after they resume transferring.
diff --git a/pytorrent/static/js/bootstrapRuntime.js b/pytorrent/static/js/bootstrapRuntime.js
index a2ee7f4..f591cf1 100644
--- a/pytorrent/static/js/bootstrapRuntime.js
+++ b/pytorrent/static/js/bootstrapRuntime.js
@@ -1 +1 @@
-export const bootstrapRuntimeSource = " let lastStaticAssetVersionCheck=0;\n async function checkStaticAssetVersion(force=false){ const now=Date.now(); if(!force && now-lastStaticAssetVersionCheck<60000) return; lastStaticAssetVersionCheck=now; try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(()=>checkStaticAssetVersion(true), 900000);\n window.addEventListener('focus',()=>checkStaticAssetVersion(false));\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n";
+export const bootstrapRuntimeSource = " let lastStaticAssetVersionCheck=0;\n async function checkStaticAssetVersion(force=false){ const now=Date.now(); if(!force && now-lastStaticAssetVersionCheck<60000) return; lastStaticAssetVersionCheck=now; try{ const r=await fetch('/api/static_hash',{cache:'no-store'}); const j=await r.json(); const current=String(window.PYTORRENT?.staticHash||''); const next=String(j.static_hash||j.version||''); if(current && next && current!==next){ window.PYTORRENT.staticHash=next; toast('A new frontend version is available. Reloading...','info'); setTimeout(()=>window.location.reload(), 600); } }catch(e){} }\n setInterval(()=>checkStaticAssetVersion(true), 900000);\n window.addEventListener('focus',()=>checkStaticAssetVersion(false));\n initSidebarShortcuts(); updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n";
diff --git a/pytorrent/static/js/sharedUi.js b/pytorrent/static/js/sharedUi.js
index 8deafe1..319dc3d 100644
--- a/pytorrent/static/js/sharedUi.js
+++ b/pytorrent/static/js/sharedUi.js
@@ -1 +1 @@
-export const sharedUiSource = " 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 isEasterEggReady(kind='click'){\n if(!easterEggEnabled) return false;\n return kind === 'loading' ? !!easterEggLoadingImageUrl : !!easterEggClickImageUrl;\n }\n function applyInitialLoaderEasterEgg(){\n const box = $('initialLoaderSpinner');\n if(!box) return;\n const fallback = '';\n box.classList.remove('initial-loader-prank');\n if(!isEasterEggReady('loading')){\n if(!box.querySelector('.spinner-border')) box.innerHTML = fallback;\n return;\n }\n const img = new Image();\n img.className = 'initial-loader-easter-egg-image';\n img.alt = 'Loading';\n img.loading = 'eager';\n img.onload = () => { box.classList.add('initial-loader-prank'); box.replaceChildren(img); };\n img.onerror = () => { box.classList.remove('initial-loader-prank'); box.innerHTML = fallback; };\n img.src = easterEggLoadingImageUrl;\n }\n function showPrankClickImage(event){\n const target = event.target?.closest?.('button, .btn, [role=button]');\n if(!target || target.disabled || event.defaultPrevented || event.button !== 0) return;\n if(!isEasterEggReady('click')) return;\n if(Math.random() > 0.14) return;\n const img = document.createElement('img');\n img.className = 'prank-click-image';\n img.src = easterEggClickImageUrl;\n img.alt = '';\n img.setAttribute('aria-hidden', 'true');\n const rect = target.getBoundingClientRect();\n const x = event.clientX || (rect.left + rect.width / 2);\n const y = event.clientY || (rect.top + rect.height / 2);\n img.style.left = `${Math.max(90, Math.min(window.innerWidth - 90, x))}px`;\n img.style.top = `${Math.max(90, Math.min(window.innerHeight - 90, y))}px`;\n document.body.appendChild(img);\n setTimeout(() => img.remove(), 1300);\n }\n document.addEventListener('click', showPrankClickImage, true);\n applyInitialLoaderEasterEgg();\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 26; }\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 = `
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 26; }\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 = `