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 = `${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 incompleteProgressColor(pct){\n if(pct <= 0) return 'transparent';\n const safePct = Math.max(0, Math.min(99.99, Number(pct || 0)));\n const rootStyle = getComputedStyle(document.documentElement);\n const hue = Math.round((safePct / 100) * 120);\n const saturation = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-saturation')) || 42;\n const baseLight = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-lightness-base')) || 29;\n const rangeLight = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-lightness-range')) || 4;\n const light = baseLight + Math.round((safePct / 100) * rangeLight);\n const base = `hsl(${hue} ${saturation}% ${light}%)`;\n const mix = Math.round(42 + (safePct / 100) * 38);\n // Note: Incomplete progress uses theme-aware saturation/lightness so light themes stay a bit more muted without changing the hue scale.\n return `color-mix(in srgb, ${base} ${mix}%, var(--bs-secondary-bg))`;\n }\n function progressBar(value, extraClass=''){\n const pct=Math.max(0,Math.min(100,Number(value||0)));\n const isComplete=pct>=100;\n const bg=isComplete?'var(--torrent-progress-complete)':incompleteProgressColor(pct);\n const done=isComplete?' is-complete':'';\n const cls=extraClass?` ${extraClass}`:'';\n return `
${esc(pct)}%
`;\n }\n function progress(t){ return progressBar(t.progress); }\n"; +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 saveSidebarStatePreference(){\n // Note: Sidebar collapsed groups are profile preferences so refreshes and browser switches keep the user's layout.\n savePreferencePatch({sidebar_labels_expanded:sidebarLabelsExpanded, sidebar_shortcuts_expanded:sidebarShortcutsExpanded}, 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 = `${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 incompleteProgressColor(pct){\n if(pct <= 0) return 'transparent';\n const safePct = Math.max(0, Math.min(99.99, Number(pct || 0)));\n const rootStyle = getComputedStyle(document.documentElement);\n const hue = Math.round((safePct / 100) * 120);\n const saturation = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-saturation')) || 42;\n const baseLight = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-lightness-base')) || 29;\n const rangeLight = Number.parseFloat(rootStyle.getPropertyValue('--torrent-progress-scale-lightness-range')) || 4;\n const light = baseLight + Math.round((safePct / 100) * rangeLight);\n const base = `hsl(${hue} ${saturation}% ${light}%)`;\n const mix = Math.round(42 + (safePct / 100) * 38);\n // Note: Incomplete progress uses theme-aware saturation/lightness so light themes stay a bit more muted without changing the hue scale.\n return `color-mix(in srgb, ${base} ${mix}%, var(--bs-secondary-bg))`;\n }\n function progressBar(value, extraClass=''){\n const pct=Math.max(0,Math.min(100,Number(value||0)));\n const isComplete=pct>=100;\n const bg=isComplete?'var(--torrent-progress-complete)':incompleteProgressColor(pct);\n const done=isComplete?' is-complete':'';\n const cls=extraClass?` ${extraClass}`:'';\n return `
${esc(pct)}%
`;\n }\n function progress(t){ return progressBar(t.progress); }\n"; diff --git a/pytorrent/static/js/stateCore.js b/pytorrent/static/js/stateCore.js index db783c6..08e9473 100644 --- a/pytorrent/static/js/stateCore.js +++ b/pytorrent/static/js/stateCore.js @@ -1 +1 @@ -export const stateCoreSource = " 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 function clampTorrentListFontSize(value){ value = Number(value || 13); if(!Number.isFinite(value)) value = 13; return Math.max(11, Math.min(16, Math.round(value))); }\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\", \"last_activity\", \"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 let torrentListFontSize = clampTorrentListFontSize(window.PYTORRENT?.torrentListFontSize || 13);\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:\"Created\"},\n {key:\"created\", dir:1, label:\"Created\"},\n {key:\"last_activity\", dir:-1, label:\"Last activity\"},\n {key:\"last_activity\", dir:1, label:\"Last activity\"},\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"; +export const stateCoreSource = " 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 function clampTorrentListFontSize(value){ value = Number(value || 13); if(!Number.isFinite(value)) value = 13; return Math.max(11, Math.min(16, Math.round(value))); }\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 let sidebarLabelsExpanded = Number(window.PYTORRENT?.sidebarLabelsExpanded || 0) !== 0;\n let sidebarShortcutsExpanded = Number(window.PYTORRENT?.sidebarShortcutsExpanded || 0) !== 0;\n const SMART_QUEUE_TECHNICAL_LABEL = String(window.PYTORRENT?.smartQueueTechnicalLabel || \"Smart Queue Stopped\").trim();\n const SMART_QUEUE_STALLED_LABEL = String(window.PYTORRENT?.smartQueueStalledLabel || \"Stalled\").trim();\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\", \"last_activity\", \"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 let torrentListFontSize = clampTorrentListFontSize(window.PYTORRENT?.torrentListFontSize || 13);\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:\"Created\"},\n {key:\"created\", dir:1, label:\"Created\"},\n {key:\"last_activity\", dir:-1, label:\"Last activity\"},\n {key:\"last_activity\", dir:1, label:\"Last activity\"},\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"; diff --git a/pytorrent/static/js/torrentFilterUi.js b/pytorrent/static/js/torrentFilterUi.js index ef7f273..04a97d0 100644 --- a/pytorrent/static/js/torrentFilterUi.js +++ b/pytorrent/static/js/torrentFilterUi.js @@ -1 +1 @@ -export const torrentFilterUiSource = " function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='post_check') return t.status==='Post-check' || !!t.post_check;\n if(type==='stopped') return !t.state && !isChecking(t) && t.status!=='Post-check' && !t.post_check;\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentErrorLog(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n box.innerHTML=labels.length?`
Labels
${labels.map(l=>``).join('')}`:'';\n bindSidebarFilterClicks(box);\n }\n"; +export const torrentFilterUiSource = " function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='post_check') return t.status==='Post-check' || !!t.post_check;\n if(type==='stopped') return !t.state && !isChecking(t) && t.status!=='Post-check' && !t.post_check;\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentErrorLog(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function alwaysVisibleLabelSet(){\n // Note: Stalled and the configured Smart Queue marker stay outside the collapsed group because they are operational labels.\n return new Set([SMART_QUEUE_STALLED_LABEL, SMART_QUEUE_TECHNICAL_LABEL].filter(Boolean));\n }\n function labelFilterButton(l, counts){\n return ``;\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=`${sidebarLabelsExpanded?1:0}|${SMART_QUEUE_TECHNICAL_LABEL}|${labels.map(l=>`${l}:${counts.get(l)}`).join('|')}`;\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n if(!labels.length){ box.innerHTML=''; return; }\n const alwaysVisible=alwaysVisibleLabelSet();\n const activeLabel=activeFilter.startsWith('label:') ? activeFilter.slice(6) : '';\n const pinned=labels.filter(l=>alwaysVisible.has(l) || l===activeLabel);\n const collapsible=labels.filter(l=>!pinned.includes(l));\n const hiddenCount=collapsible.length;\n const collapsed=!sidebarLabelsExpanded;\n const expandedLabels=collapsed ? [] : collapsible;\n const toggle=hiddenCount ? `` : '';\n box.innerHTML=`
Labels
${pinned.map(l=>labelFilterButton(l,counts)).join('')}${toggle}
${expandedLabels.map(l=>labelFilterButton(l,counts)).join('')}
`;\n bindSidebarFilterClicks(box);\n $('labelFiltersToggle')?.addEventListener('click',()=>{ sidebarLabelsExpanded=!sidebarLabelsExpanded; lastLabelFiltersSignature=''; saveSidebarStatePreference(); renderLabelFilters(true); });\n }\n function initSidebarShortcuts(){\n const box=$('shortcutList');\n const btn=$('shortcutToggle');\n if(!box || !btn) return;\n const apply=()=>{\n box.classList.toggle('is-collapsed', !sidebarShortcutsExpanded);\n btn.setAttribute('aria-expanded', sidebarShortcutsExpanded ? 'true' : 'false');\n btn.innerHTML=` ${sidebarShortcutsExpanded?'Hide shortcuts':'Show shortcuts'}${box.querySelectorAll('.shortcut').length}`;\n };\n apply();\n btn.addEventListener('click',()=>{ sidebarShortcutsExpanded=!sidebarShortcutsExpanded; apply(); saveSidebarStatePreference(); });\n }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 2f9c3f7..1d55a10 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -353,8 +353,9 @@ body { grid-template-columns: minmax(0, 1fr) auto; gap: 0.1rem 0.45rem; align-items: center; - margin-bottom: 0.12rem; - padding: 0.34rem 0.5rem; + margin-bottom: 0.08rem; + /* Note: Main sidebar filters are intentionally compact so labels and tools fit without extra scrolling. */ + padding: 0.25rem 0.45rem; border: 0; border-radius: 0.55rem; background: transparent; @@ -387,9 +388,9 @@ body { display: block; margin-top: 0.05rem; color: var(--bs-secondary-color); - font-size: 0.68rem; + font-size: 0.64rem; font-weight: 400; - line-height: 1.15; + line-height: 1.05; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; @@ -405,6 +406,39 @@ body { color: var(--bs-secondary-color); padding: 0.15rem 0.5rem; } + +.sidebar-collapse-toggle { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.35rem; + align-items: center; + margin: 0.08rem 0; + padding: 0.24rem 0.45rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--bs-secondary-color); + font-size: 0.78rem; + text-align: left; +} +.sidebar-collapse-toggle:hover { + background: var(--bs-secondary-bg); + color: var(--bs-body-color); +} +.sidebar-collapse-toggle > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sidebar-collapse-toggle > span:last-child { + font-weight: 700; + text-align: right; +} +.sidebar-collapsible.is-collapsed { + display: none; +} .content { min-width: 0; min-height: 0; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 6d14604..e36984c 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -78,17 +78,22 @@

-
Shortcuts
-
Ctrl+A — select visible
-
Ctrl+I — invert visible
-
Space — start
-
P — pause
-
S — stop
-
R — resume
-
M — move
-
Esc — clear selection
-
Delete — remove
-
Ctrl+O — add
Ctrl+S — download .torrent
+
Shortcuts
+ +
@@ -407,7 +412,7 @@
- + diff --git a/scripts/mock b/scripts/mock new file mode 100755 index 0000000..bfc4533 --- /dev/null +++ b/scripts/mock @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 +"""Development SCGI/XML-RPC rTorrent mock for pyTorrent.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import random +import socketserver +import sqlite3 +import sys +import threading +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlparse +from xmlrpc.client import Binary, Fault, dumps, loads + +BASE_DIR = Path(__file__).resolve().parents[1] +DEFAULT_STATE_PATH = BASE_DIR / "data" / "mock_rtorrent_state.json" +LABELS = ["Smart Queue Stopped", "Stalled", "movies", "series", "music", "books", "linux", "archive", "games", "work", "private", "backup"] +TRACKERS = [ + "udp://tracker.opentrackr.org:1337/announce", + "udp://open.stealth.si:80/announce", + "udp://tracker.torrent.eu.org:451/announce", + "https://tracker.example.dev/announce", +] +CLIENTS = ["qBittorrent/4.6", "Transmission/4.0", "libtorrent/2.0", "Deluge/2.1", "rtorrent/0.9"] + + +def xmlrpc_safe(value: Any) -> Any: + """Convert large integers to strings because XML-RPC int is 32-bit in Python clients.""" + if isinstance(value, bool): + return value + if isinstance(value, int) and not (-2_147_483_648 <= value <= 2_147_483_647): + return str(value) + if isinstance(value, list): + return [xmlrpc_safe(item) for item in value] + if isinstance(value, tuple): + return tuple(xmlrpc_safe(item) for item in value) + if isinstance(value, dict): + return {key: xmlrpc_safe(item) for key, item in value.items()} + return value + + +def human_now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +class MockRtorrentState: + """Mutable in-memory rTorrent-like state with optional JSON persistence.""" + + def __init__(self, count: int, seed: int, state_file: Path | None = None, persist: bool = False, disk_total_gb: int = 4096, disk_used_percent: float = 68.0): + self.lock = threading.RLock() + self.started_at = time.time() + self.state_file = state_file + self.persist = persist + self.disk_total_bytes = max(1, int(disk_total_gb)) * 1024 * 1024 * 1024 + self.disk_used_percent = max(0.0, min(99.9, float(disk_used_percent))) + self.config: dict[str, Any] = { + "network.port_range": "49164-49164", + "network.xmlrpc.size_limit": "16M", + "throttle.global_down.max_rate": 0, + "throttle.global_up.max_rate": 0, + "system.client_version": "mock-rtorrent/0.1", + "system.library_version": "mock-libtorrent/0.13", + "directory.default": "/mock/downloads", + "session.path": "/mock/session", + "system.filesystem.total": self.disk_total_bytes, + "system.filesystem.used_percent": self.disk_used_percent, + } + self.torrents: list[dict[str, Any]] = [] + self.by_hash: dict[str, dict[str, Any]] = {} + if persist and state_file and state_file.is_file(): + self.load() + else: + self.generate(count=count, seed=seed) + + def generate(self, count: int, seed: int) -> None: + """Create a large deterministic torrent list for UI and API load testing.""" + rng = random.Random(seed) + now = int(time.time()) + self.torrents = [] + for index in range(max(1, count)): + size = rng.randint(64, 96_000) * 1024 * 1024 + complete = index % 5 in (0, 1, 2) + progress = 1.0 if complete else rng.uniform(0.01, 0.98) + completed = size if complete else int(size * progress) + active = index % 7 not in (0, 3) + state = 1 if active or index % 11 == 0 else 0 + label = LABELS[index % len(LABELS)] + if index % 19 == 0: + label = f"{label}, project-{index % 37}" + torrent_hash = hashlib.sha1(f"pyTorrent-mock-{seed}-{index}".encode()).hexdigest().upper() + down_rate = 0 if complete or not active else rng.randint(50_000, 8_000_000) + up_rate = 0 if not active else rng.randint(5_000, 2_000_000) + torrent = { + "hash": torrent_hash, + "name": f"Mock Torrent {index + 1:05d} - {label}", + "state": state, + "complete": 1 if complete else 0, + "size": size, + "completed": completed, + "ratio": rng.randint(0, 4500), + "up_rate": up_rate, + "down_rate": down_rate, + "up_total": int(size * rng.uniform(0.0, 3.0)), + "down_total": completed, + "peers": rng.randint(0, 150), + "seeds": rng.randint(0, 500), + "priority": rng.choice([0, 1, 2, 3]), + "directory": f"/mock/downloads/{label.split(',')[0]}", + "base_path": f"/mock/downloads/{label.split(',')[0]}/Mock Torrent {index + 1:05d}", + "created": now - rng.randint(60, 365 * 86400), + "label": label, + "ratio_group": rng.choice(["", "default", "long-seed", "archive"]), + "message": "Tracker timeout" if index % 97 == 0 else "", + "hashing": 1 if index % 211 == 0 else 0, + "is_active": 1 if active else 0, + "is_multi_file": 1, + "last_activity": now - rng.randint(0, 7 * 86400), + "completed_at": now - rng.randint(0, 180 * 86400) if complete else 0, + "trackers": rng.sample(TRACKERS, k=rng.randint(1, len(TRACKERS))), + "files": self.make_files(index, size, completed, rng), + "peers_list": self.make_peers(rng), + } + self.torrents.append(torrent) + self.reindex() + self.save() + + def make_files(self, index: int, size: int, completed: int, rng: random.Random) -> list[dict[str, Any]]: + """Split one torrent into plausible files with priorities and completion.""" + file_count = rng.randint(1, 18) + remaining_size = size + remaining_done = completed + files = [] + for file_index in range(file_count): + if file_index == file_count - 1: + file_size = remaining_size + else: + file_size = rng.randint(1, max(1, remaining_size // max(1, file_count - file_index))) + file_done = min(file_size, remaining_done) + remaining_size -= file_size + remaining_done -= file_done + chunks = max(1, file_size // (1024 * 1024)) + files.append({ + "path": f"Mock Torrent {index + 1:05d}/file-{file_index + 1:03d}.bin", + "size": file_size, + "completed_chunks": int(chunks * (file_done / file_size)) if file_size else 0, + "size_chunks": chunks, + "priority": rng.choice([0, 1, 1, 2]), + }) + return files + + def make_peers(self, rng: random.Random) -> list[list[Any]]: + """Generate peer rows matching p.multicall fields used by pyTorrent.""" + rows = [] + for _ in range(rng.randint(3, 40)): + rows.append([ + f"{rng.randint(11, 223)}.{rng.randint(0, 255)}.{rng.randint(0, 255)}.{rng.randint(1, 254)}", + rng.choice(CLIENTS), + rng.randint(0, 100), + rng.randint(0, 2_000_000), + rng.randint(0, 1_000_000), + rng.randint(1024, 65535), + rng.choice([0, 1]), + rng.choice([0, 1]), + 0, + 0, + ]) + return rows + + def reindex(self) -> None: + self.by_hash = {str(t["hash"]): t for t in self.torrents} + + def load(self) -> None: + """Load optional persisted mock state for repeatable development sessions.""" + data = json.loads(self.state_file.read_text(encoding="utf-8")) + self.config.update(data.get("config") or {}) + self.torrents = list(data.get("torrents") or []) + self.reindex() + + def save(self) -> None: + """Persist state only when --persist is enabled; default state lasts until restart.""" + if not self.persist or not self.state_file: + return + self.state_file.parent.mkdir(parents=True, exist_ok=True) + tmp = self.state_file.with_suffix(".tmp") + tmp.write_text(json.dumps({"updated_at": human_now(), "config": self.config, "torrents": self.torrents}), encoding="utf-8") + tmp.replace(self.state_file) + + def tick(self) -> None: + """Advance speeds, totals and progress on each RPC request.""" + now = int(time.time()) + for index, torrent in enumerate(self.torrents): + if not torrent.get("state") or not torrent.get("is_active"): + torrent["down_rate"] = 0 + torrent["up_rate"] = 0 + continue + wobble = 0.75 + ((now + index) % 9) / 18 + if torrent.get("complete"): + torrent["down_rate"] = 0 + torrent["up_rate"] = int((20_000 + (index % 500) * 1500) * wobble) + torrent["up_total"] += max(0, int(torrent["up_rate"] / 3)) + else: + torrent["down_rate"] = int((80_000 + (index % 700) * 9000) * wobble) + torrent["up_rate"] = int((5_000 + (index % 120) * 900) * wobble) + torrent["completed"] = min(torrent["size"], torrent["completed"] + max(1, int(torrent["down_rate"] / 2))) + torrent["down_total"] = torrent["completed"] + if torrent["completed"] >= torrent["size"]: + torrent["complete"] = 1 + torrent["completed_at"] = now + torrent["last_activity"] = now + + def torrent_row_value(self, torrent: dict[str, Any], field: str) -> Any: + """Map rTorrent d.* fields to mock torrent values.""" + mapping = { + "d.hash=": "hash", "d.name=": "name", "d.state=": "state", "d.complete=": "complete", + "d.size_bytes=": "size", "d.completed_bytes=": "completed", "d.ratio=": "ratio", + "d.up.rate=": "up_rate", "d.down.rate=": "down_rate", "d.up.total=": "up_total", + "d.down.total=": "down_total", "d.peers_connected=": "peers", "d.peers_complete=": "seeds", + "d.priority=": "priority", "d.directory=": "directory", "d.base_path=": "base_path", + "d.creation_date=": "created", "d.custom1=": "label", "d.custom=py_ratio_group": "ratio_group", + "d.message=": "message", "d.hashing=": "hashing", "d.is_active=": "is_active", + "d.is_multi_file=": "is_multi_file", "d.timestamp.last_active=": "last_activity", + "d.timestamp.finished=": "completed_at", + } + return torrent.get(mapping.get(field, ""), "") + + def call(self, method: str, args: tuple[Any, ...]) -> Any: + """Handle the subset of rTorrent XML-RPC methods needed by pyTorrent.""" + with self.lock: + self.tick() + if method in self.config: + return self.config[method] + if method.endswith(".set") and method.replace(".set", "") in self.config: + value = args[-1] if args else 0 + self.config[method.replace(".set", "")] = value + self.save() + return 0 + if method == "d.multicall2": + fields = args[2:] + return [[self.torrent_row_value(t, f) for f in fields] for t in self.torrents] + if method == "d.multicall": + fields = args[1:] + return [[self.torrent_row_value(t, f) for f in fields] for t in self.torrents] + if method == "p.multicall": + torrent = self.by_hash.get(str(args[0])) + return torrent.get("peers_list", []) if torrent else [] + if method == "f.multicall": + torrent = self.by_hash.get(str(args[0])) + fields = args[2:] + return [self.file_row(file, fields) for file in (torrent or {}).get("files", [])] + if method == "t.multicall": + torrent = self.by_hash.get(str(args[0]) or str(args[1] if len(args) > 1 else "")) + return [[tracker, 1, 120 + i, 30 + i, 5000 + i] for i, tracker in enumerate((torrent or {}).get("trackers", []))] + if method.startswith("d."): + return self.call_download_method(method, args) + if method.startswith("t."): + return self.call_tracker_method(method, args) + if method.startswith("f.priority.set"): + return 0 + if method.startswith("load.raw"): + return self.add_loaded_torrent(args) + if method.startswith("execute"): + return self.call_execute(method, args) + raise Fault(1, f"Mock method not implemented: {method}") + + + def disk_usage_output(self, path: str) -> str: + """Return df -Pk compatible disk usage for pyTorrent disk monitor calls.""" + # Note: Mock disk usage is synthetic and stable, so the footer disk monitor can be tested without real mounts. + clean_path = str(path or self.config.get("directory.default") or "/mock/downloads") + if not clean_path.startswith("/"): + clean_path = f"/mock/downloads/{clean_path}" + total_kb = max(1, self.disk_total_bytes // 1024) + wave = ((int(time.time()) // 30) % 11 - 5) / 10 + used_percent = max(0.0, min(99.9, self.disk_used_percent + wave)) + used_kb = int(total_kb * used_percent / 100) + free_kb = max(0, total_kb - used_kb) + percent = int(round((used_kb / total_kb) * 100)) if total_kb else 0 + return f"OK\t{total_kb}\t{used_kb}\t{free_kb}\t{percent}\t{clean_path}\n" + + def browse_output(self, path: str) -> str: + """Return a lightweight path browser response used by pyTorrent move/path pickers.""" + clean_path = str(path or self.config.get("directory.default") or "/mock/downloads").rstrip("/") or "/" + dirs = ["movies", "series", "music", "linux", "archive", "incoming", "completed"] + lines = [f"D\t{name}\t{clean_path}/{name}" for name in dirs] + total_kb, used_kb, free_kb, percent = self.disk_df_parts() + lines.append(f"M\t{len(dirs)}\t{len(self.torrents)}") + lines.append(f"F\t{total_kb} {used_kb} {free_kb} {percent}%") + return "\n".join(lines) + + def disk_df_parts(self) -> tuple[int, int, int, int]: + """Return total, used, free and percent values in KiB.""" + total_kb = max(1, self.disk_total_bytes // 1024) + used_kb = int(total_kb * self.disk_used_percent / 100) + free_kb = max(0, total_kb - used_kb) + percent = int(round((used_kb / total_kb) * 100)) if total_kb else 0 + return total_kb, used_kb, free_kb, percent + + def call_execute(self, method: str, args: tuple[Any, ...]) -> str: + """Handle shell-backed rTorrent helpers used for disk and path monitoring.""" + marker_args = [str(item) for item in args] + if "pytorrent-df" in marker_args: + marker_index = marker_args.index("pytorrent-df") + path = marker_args[marker_index + 1] if marker_index + 1 < len(marker_args) else str(self.config.get("directory.default") or "/mock/downloads") + return self.disk_usage_output(path) + if "pytorrent-browse" in marker_args: + marker_index = marker_args.index("pytorrent-browse") + path = marker_args[marker_index + 1] if marker_index + 1 < len(marker_args) else str(self.config.get("directory.default") or "/mock/downloads") + return self.browse_output(path) + script = " ".join(marker_args) + if "/proc/stat" in script and "/proc/meminfo" in script: + return "17.4 61.2" + if "df -Pk" in script: + return self.disk_usage_output(str(self.config.get("directory.default") or "/mock/downloads")) + return "" + + def file_row(self, file: dict[str, Any], fields: tuple[Any, ...]) -> list[Any]: + """Map rTorrent f.* fields to mock file values.""" + mapping = { + "f.path=": "path", "f.size_bytes=": "size", "f.completed_chunks=": "completed_chunks", + "f.size_chunks=": "size_chunks", "f.priority=": "priority", "f.range_first=": "range_first", + "f.range_second=": "range_second", + } + return [file.get(mapping.get(str(field), ""), 0) for field in fields] + + def call_download_method(self, method: str, args: tuple[Any, ...]) -> Any: + """Read or mutate individual torrent attributes and state.""" + torrent_hash = str(args[0] if args else "") + torrent = self.by_hash.get(torrent_hash) + if not torrent: + return "" if method not in {"d.state", "d.is_active", "d.is_multi_file"} else 0 + readers = { + "d.name": "name", "d.state": "state", "d.directory": "directory", "d.base_path": "base_path", + "d.is_multi_file": "is_multi_file", "d.is_active": "is_active", "d.custom1": "label", + "d.bitfield": "bitfield", + } + if method in readers: + return torrent.get(readers[method], "") + if method == "d.custom1.set": + torrent["label"] = str(args[1] if len(args) > 1 else "") + elif method == "d.directory.set": + torrent["directory"] = str(args[1] if len(args) > 1 else torrent["directory"]) + elif method == "d.custom.set": + if len(args) > 2 and str(args[1]) == "py_ratio_group": + torrent["ratio_group"] = str(args[2]) + elif method in {"d.start", "d.open", "d.try_start", "d.resume"}: + torrent.update({"state": 1, "is_active": 1, "message": ""}) + elif method in {"d.stop", "d.close", "d.pause"}: + torrent.update({"state": 0, "is_active": 0, "down_rate": 0, "up_rate": 0}) + elif method == "d.check_hash": + torrent.update({"hashing": 1, "message": "Hash check queued"}) + elif method == "d.update_priorities": + return 0 + self.save() + return 0 + + def call_tracker_method(self, method: str, args: tuple[Any, ...]) -> Any: + """Return tracker details for sidebar filters and detail panes.""" + target = str(args[0] if args else "") + torrent_hash, _, suffix = target.partition(":t") + torrent = self.by_hash.get(torrent_hash) + index = int(suffix or 0) if suffix.isdigit() else 0 + trackers = (torrent or {}).get("trackers", []) + if method == "t.url": + return trackers[index] if 0 <= index < len(trackers) else "" + if method == "t.is_enabled": + return 1 + if method == "t.activity_time_last": + return int(time.time()) - 300 + if method == "t.activity_time_next": + return int(time.time()) + 1800 + if method == "t.scrape_time_last": + return int(time.time()) - 600 + return 0 + + def add_loaded_torrent(self, args: tuple[Any, ...]) -> int: + """Add a lightweight mock torrent when the app uploads a torrent or magnet.""" + index = len(self.torrents) + torrent_hash = hashlib.sha1(f"mock-added-{time.time()}-{index}".encode()).hexdigest().upper() + size = 1024 * 1024 * 1024 + label = "mock-added" + directory = "/mock/downloads" + for item in args: + text = str(item) + if text.startswith("d.custom1.set="): + label = text.split("=", 1)[1] + if text.startswith("d.directory.set="): + directory = text.split("=", 1)[1] + self.torrents.append({ + "hash": torrent_hash, "name": f"Mock Added Torrent {index + 1}", "state": 1, "complete": 0, + "size": size, "completed": 0, "ratio": 0, "up_rate": 0, "down_rate": 512_000, + "up_total": 0, "down_total": 0, "peers": 8, "seeds": 12, "priority": 1, + "directory": directory, "base_path": f"{directory}/Mock Added Torrent {index + 1}", + "created": int(time.time()), "label": label, "ratio_group": "", "message": "", + "hashing": 0, "is_active": 1, "is_multi_file": 1, "last_activity": int(time.time()), + "completed_at": 0, "trackers": TRACKERS[:2], "files": self.make_files(index, size, 0, random.Random(index)), + "peers_list": self.make_peers(random.Random(index)), + }) + self.reindex() + self.save() + return 0 + + +class ScgiXmlRpcHandler(socketserver.BaseRequestHandler): + """Single-request SCGI netstring parser that returns XML-RPC responses.""" + + state: MockRtorrentState + + def handle(self) -> None: + try: + body = self.read_scgi_body() + params, method = loads(body) + result = self.state.call(method, tuple(params)) + payload = dumps((xmlrpc_safe(result),), methodresponse=True, allow_none=True).encode("utf-8") + except Fault as exc: + payload = dumps(exc, allow_none=True).encode("utf-8") + except Exception as exc: + payload = dumps(Fault(1, f"Mock server error: {exc}"), allow_none=True).encode("utf-8") + header = f"Status: 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {len(payload)}\r\n\r\n".encode("ascii") + self.request.sendall(header + payload) + + def read_scgi_body(self) -> bytes: + """Read SCGI headers and request body from a netstring frame.""" + digits = bytearray() + while True: + char = self.request.recv(1) + if not char: + raise ConnectionError("empty SCGI request") + if char == b":": + break + digits.extend(char) + header_len = int(digits.decode("ascii")) + headers = self.recv_exact(header_len) + comma = self.recv_exact(1) + if comma != b",": + raise ValueError("invalid SCGI netstring") + parts = headers.split(b"\0") + header_map = {parts[i].decode(): parts[i + 1].decode() for i in range(0, len(parts) - 1, 2) if parts[i]} + return self.recv_exact(int(header_map.get("CONTENT_LENGTH", "0"))) + + def recv_exact(self, size: int) -> bytes: + """Receive exactly size bytes or fail fast on disconnected clients.""" + chunks = [] + left = size + while left > 0: + chunk = self.request.recv(left) + if not chunk: + raise ConnectionError("client disconnected") + chunks.append(chunk) + left -= len(chunk) + return b"".join(chunks) + + +class ThreadingScgiServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +def fallback_db_path() -> Path: + """Resolve pyTorrent DB path without importing the Flask application package.""" + raw = os.getenv("PYTORRENT_DB_PATH", str(BASE_DIR / "data" / "pytorrent.sqlite3")) + path = Path(raw) + return path if path.is_absolute() else BASE_DIR / path + + +def register_profile(host: str, port: int, name: str) -> None: + """Create or update a pyTorrent profile pointing at this mock server.""" + now = human_now() + scgi_url = f"scgi://{host}:{port}/RPC2" + db_path = fallback_db_path() + db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(db_path) as conn: + conn.row_factory = sqlite3.Row + conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT, email TEXT, display_name TEXT, external_auth_provider TEXT, external_subject TEXT, role TEXT DEFAULT 'user', is_active INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT)") + conn.execute("CREATE TABLE IF NOT EXISTS rtorrent_profiles (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, scgi_url TEXT NOT NULL, is_default INTEGER DEFAULT 0, timeout_seconds INTEGER DEFAULT 5, max_parallel_jobs INTEGER DEFAULT 5, light_parallel_jobs INTEGER DEFAULT 4, light_job_timeout_seconds INTEGER DEFAULT 300, heavy_job_timeout_seconds INTEGER DEFAULT 7200, pending_job_timeout_seconds INTEGER DEFAULT 900, is_remote INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)") + conn.execute("INSERT OR IGNORE INTO users(id, username, role, is_active, created_at, updated_at) VALUES(1, 'admin', 'admin', 1, ?, ?)", (now, now)) + row = conn.execute("SELECT id FROM rtorrent_profiles WHERE user_id=? AND name=?", (1, name)).fetchone() + if row: + conn.execute("UPDATE rtorrent_profiles SET scgi_url=?, timeout_seconds=?, is_remote=0, updated_at=? WHERE id=?", (scgi_url, 10, now, row["id"])) + else: + conn.execute( + "INSERT INTO rtorrent_profiles(user_id,name,scgi_url,is_default,timeout_seconds,is_remote,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)", + (1, name, scgi_url, 0, 10, 0, now, now), + ) + print(f"Registered pyTorrent profile '{name}' -> {scgi_url}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a large development rTorrent SCGI mock for pyTorrent.") + parser.add_argument("--host", default="127.0.0.1", help="SCGI bind host. Default: 127.0.0.1") + parser.add_argument("--port", type=int, default=5001, help="SCGI bind port. Default: 5001") + parser.add_argument("--count", type=int, default=int(os.getenv("PYTORRENT_MOCK_TORRENTS", "2500")), help="Number of generated torrents.") + parser.add_argument("--seed", type=int, default=42, help="Deterministic data seed.") + parser.add_argument("--persist", action="store_true", help="Persist mock state to JSON across restarts.") + parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH, help="JSON state path used with --persist.") + parser.add_argument("--disk-total-gb", type=int, default=int(os.getenv("PYTORRENT_MOCK_DISK_TOTAL_GB", "4096")), help="Synthetic disk size exposed to pyTorrent disk monitor.") + parser.add_argument("--disk-used-percent", type=float, default=float(os.getenv("PYTORRENT_MOCK_DISK_USED_PERCENT", "68")), help="Synthetic used disk percentage exposed to pyTorrent disk monitor.") + parser.add_argument("--register-profile", action="store_true", help="Create or update a pyTorrent profile for this mock.") + parser.add_argument("--profile-name", default="Mock rTorrent", help="Profile name used with --register-profile.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + state = MockRtorrentState(count=args.count, seed=args.seed, state_file=args.state_file, persist=args.persist, disk_total_gb=args.disk_total_gb, disk_used_percent=args.disk_used_percent) + ScgiXmlRpcHandler.state = state + if args.register_profile: + register_profile(args.host, args.port, args.profile_name) + with ThreadingScgiServer((args.host, args.port), ScgiXmlRpcHandler) as server: + print(f"Mock rTorrent SCGI listening on scgi://{args.host}:{args.port}/RPC2 with {len(state.torrents)} torrents") + print(f"Mock disk monitor: {args.disk_total_gb} GiB total, {args.disk_used_percent}% used") + print("Use Ctrl+C to stop. Without --persist, changes live only until restart.") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nMock rTorrent stopped") + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())