From c21a3ad944921ca55332042bd322b72bba4b0ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 7 Jun 2026 23:29:07 +0200 Subject: [PATCH] fix disk monitor preferences profile ownership --- pytorrent/db.py | 54 ++++++++++++++++++++++++- pytorrent/services/backup.py | 12 +++++- pytorrent/services/preferences.py | 40 +++++++++++++++--- pytorrent/services/workers.py | 2 +- pytorrent/static/js/diskMonitor.js | 2 +- pytorrent/static/js/preferencesTools.js | 2 +- pytorrent/static/js/runtimeState.js | 2 +- pytorrent/templates/index.html | 4 +- 8 files changed, 102 insertions(+), 16 deletions(-) diff --git a/pytorrent/db.py b/pytorrent/db.py index fe3dd95..f031c00 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -137,8 +137,8 @@ CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at); CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at); CREATE TABLE IF NOT EXISTS disk_monitor_preferences ( + profile_id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, - profile_id INTEGER NOT NULL, paths_json TEXT, mode TEXT DEFAULT 'default', selected_path TEXT, @@ -146,10 +146,10 @@ CREATE TABLE IF NOT EXISTS disk_monitor_preferences ( stop_threshold INTEGER DEFAULT 98, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ); +CREATE INDEX IF NOT EXISTS idx_disk_monitor_preferences_owner ON disk_monitor_preferences(user_id); CREATE TABLE IF NOT EXISTS labels ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -531,6 +531,55 @@ def create_schema(conn: sqlite3.Connection) -> None: 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() @@ -584,6 +633,7 @@ def init_db(): except sqlite3.OperationalError: pass create_schema(conn) + ensure_profile_scoped_disk_monitor_preferences(conn) seed_default_user(conn) try: from .services.auth import ensure_admin_user diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index 4f7a6ea..5afef88 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -28,7 +28,7 @@ PROFILE_BACKUP_TABLES = [ PROFILE_TABLE_SCOPES = { "rtorrent_profiles": "profile_id", "profile_preferences": "user_profile", - "disk_monitor_preferences": "user_profile", + "disk_monitor_preferences": "profile", "labels": "profile", "ratio_groups": "profile", "rss_feeds": "profile", @@ -44,7 +44,7 @@ PROFILE_TABLE_SCOPES = { PROFILE_TABLE_FILTERS = { "rtorrent_profiles": "id=?", "profile_preferences": "user_id=? AND profile_id=?", - "disk_monitor_preferences": "user_id=? AND profile_id=?", + "disk_monitor_preferences": "profile_id=?", "labels": "profile_id=?", "ratio_groups": "profile_id=?", "rss_feeds": "profile_id=?", @@ -273,6 +273,12 @@ def restore_app_backup(backup_id: int, user_id: int | None = None) -> dict: return {"restored": restored, "backup_type": "app"} +def _single_profile_row(rows: list[dict]) -> list[dict]: + if not rows: + return [] + return [sorted(rows, key=lambda row: str(row.get("updated_at") or row.get("created_at") or ""), reverse=True)[0]] + + def _rewrite_profile_row(table: str, row: dict, user_id: int, target_profile_id: int) -> dict: clean = dict(row) if table == "rtorrent_profiles": @@ -305,6 +311,8 @@ def restore_profile_backup(backup_id: int, target_profile_id: int, user_id: int try: for table in PROFILE_BACKUP_TABLES: rows = tables.get(table) or [] + if table == "disk_monitor_preferences": + rows = _single_profile_row([dict(row) for row in rows]) where = PROFILE_TABLE_FILTERS.get(table) params = _profile_filter_params(table, user_id, int(target_profile_id)) conn.execute(f"DELETE FROM {table} WHERE {where}", params) diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 9474a72..151acce 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -296,17 +296,39 @@ def legacy_disk_monitor_preferences(user_id: int | None = None) -> dict: return _normalize_disk_monitor(row) +def _disk_monitor_owner_label(row: dict | None) -> str: + if not row: + return "" + return str(row.get("owner_display_name") or row.get("owner_username") or row.get("owner_email") or (f"user #{row.get('user_id')}" if row.get("user_id") else "")).strip() + + def get_disk_monitor_preferences(profile_id: int | None = None, user_id: int | None = None) -> dict: user_id = user_id or auth.current_user_id() or default_user_id() profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0) if not profile_id: return legacy_disk_monitor_preferences(user_id) + if not auth.can_access_profile(profile_id, user_id): + return legacy_disk_monitor_preferences(user_id) with connect() as conn: - row = conn.execute("SELECT * FROM disk_monitor_preferences WHERE user_id=? AND profile_id=?", (user_id, profile_id)).fetchone() + row = conn.execute( + """ + SELECT d.*, u.username AS owner_username, u.display_name AS owner_display_name, u.email AS owner_email + FROM disk_monitor_preferences d + LEFT JOIN users u ON u.id=d.user_id + WHERE d.profile_id=? + """, + (profile_id,), + ).fetchone() if row: - return _normalize_disk_monitor(row) + clean = _normalize_disk_monitor(row) + clean["disk_monitor_owner_user_id"] = int(row.get("user_id") or 0) + clean["disk_monitor_owner_label"] = _disk_monitor_owner_label(row) + return clean # Backward-compatible seed: existing global disk monitor values become defaults for first use of a profile. - return legacy_disk_monitor_preferences(user_id) + clean = legacy_disk_monitor_preferences(user_id) + clean["disk_monitor_owner_user_id"] = 0 + clean["disk_monitor_owner_label"] = "" + return clean def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: int | None = None) -> dict: @@ -314,6 +336,8 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i profile_id = int(profile_id or _active_profile_id_for_user(user_id) or 0) if not profile_id: return legacy_disk_monitor_preferences(user_id) + if not auth.can_write_profile(profile_id, user_id): + raise PermissionError("No write access to profile") current = get_disk_monitor_preferences(profile_id, user_id) merged = dict(current) for key in ("disk_monitor_paths_json", "disk_monitor_mode", "disk_monitor_selected_path", "disk_monitor_stop_enabled", "disk_monitor_stop_threshold"): @@ -323,10 +347,14 @@ def save_disk_monitor_preferences(profile_id: int | None, data: dict, user_id: i now = utcnow() with connect() as conn: conn.execute( - "INSERT INTO disk_monitor_preferences(user_id,profile_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) " - "ON CONFLICT(user_id,profile_id) DO UPDATE SET paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at", - (user_id, profile_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now), + "INSERT INTO disk_monitor_preferences(profile_id,user_id,paths_json,mode,selected_path,stop_enabled,stop_threshold,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(profile_id) DO UPDATE SET user_id=excluded.user_id, paths_json=excluded.paths_json, mode=excluded.mode, selected_path=excluded.selected_path, stop_enabled=excluded.stop_enabled, stop_threshold=excluded.stop_threshold, updated_at=excluded.updated_at", + (profile_id, user_id, clean["disk_monitor_paths_json"], clean["disk_monitor_mode"], clean["disk_monitor_selected_path"], clean["disk_monitor_stop_enabled"], clean["disk_monitor_stop_threshold"], now, now), ) + clean["disk_monitor_owner_user_id"] = int(user_id) + with connect() as conn: + row = conn.execute("SELECT display_name AS owner_display_name, username AS owner_username, email AS owner_email, id AS user_id FROM users WHERE id=?", (user_id,)).fetchone() + clean["disk_monitor_owner_label"] = _disk_monitor_owner_label(row) return clean diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index 5c262d3..5104e4a 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -247,7 +247,7 @@ def _clear_disk_refresh_cache(profile_id: int) -> None: def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None: _clear_disk_refresh_cache(profile_id) - # Note: The browser performs the fresh /api/system/disk read so user-specific disk monitor preferences stay respected. + # Note: The browser performs the fresh /api/system/disk read so profile-scoped disk monitor preferences stay respected. _emit("disk_refresh_requested", { "profile_id": int(profile_id), "hash_count": int(hash_count or 0), diff --git a/pytorrent/static/js/diskMonitor.js b/pytorrent/static/js/diskMonitor.js index 2bc43d3..288898c 100644 --- a/pytorrent/static/js/diskMonitor.js +++ b/pytorrent/static/js/diskMonitor.js @@ -1 +1 @@ -export const diskMonitorSource = " function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveEasterEggPrefs(){ easterEggEnabled=!!$('easterEggEnabled')?.checked; easterEggLoadingImageUrl=String($('easterEggLoadingImageUrl')?.value||'').trim(); easterEggClickImageUrl=String($('easterEggClickImageUrl')?.value||'').trim(); try{ const res=await post('/api/preferences',{easter_egg_enabled:easterEggEnabled,easter_egg_loading_image_url:easterEggLoadingImageUrl,easter_egg_click_image_url:easterEggClickImageUrl}); const prefs=res.preferences||{}; easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0; easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url||'').trim(); easterEggClickImageUrl=String(prefs.easter_egg_click_image_url||'').trim(); if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; applyInitialLoaderEasterEgg(); scheduleRender(true); toast('Easter egg preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }"; +export const diskMonitorSource = " function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n if($('diskMonitorOwner')) $('diskMonitorOwner').textContent=diskMonitorOwnerLabel ? `Owner: ${diskMonitorOwnerLabel}` : '';\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveEasterEggPrefs(){ easterEggEnabled=!!$('easterEggEnabled')?.checked; easterEggLoadingImageUrl=String($('easterEggLoadingImageUrl')?.value||'').trim(); easterEggClickImageUrl=String($('easterEggClickImageUrl')?.value||'').trim(); try{ const res=await post('/api/preferences',{easter_egg_enabled:easterEggEnabled,easter_egg_loading_image_url:easterEggLoadingImageUrl,easter_egg_click_image_url:easterEggClickImageUrl}); const prefs=res.preferences||{}; easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0; easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url||'').trim(); easterEggClickImageUrl=String(prefs.easter_egg_click_image_url||'').trim(); if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; applyInitialLoaderEasterEgg(); scheduleRender(true); toast('Easter egg preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||diskMonitorOwnerLabel||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }"; diff --git a/pytorrent/static/js/preferencesTools.js b/pytorrent/static/js/preferencesTools.js index 538f011..58c4b00 100644 --- a/pytorrent/static/js/preferencesTools.js +++ b/pytorrent/static/js/preferencesTools.js @@ -1 +1 @@ -export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }"; +export const preferencesToolsSource = " async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n easterEggEnabled=Number(prefs.easter_egg_enabled ?? (easterEggEnabled?1:0))!==0;\n easterEggLoadingImageUrl=String(prefs.easter_egg_loading_image_url ?? easterEggLoadingImageUrl ?? '').trim();\n easterEggClickImageUrl=String(prefs.easter_egg_click_image_url ?? easterEggClickImageUrl ?? '').trim();\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n diskMonitorOwnerLabel=String(prefs.disk_monitor_owner_label||'').trim();\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n torrentListFontSize=clampTorrentListFontSize(prefs.torrent_list_font_size||torrentListFontSize||13);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('easterEggEnabled')) $('easterEggEnabled').checked=easterEggEnabled; if($('easterEggLoadingImageUrl')) $('easterEggLoadingImageUrl').value=easterEggLoadingImageUrl; if($('easterEggClickImageUrl')) $('easterEggClickImageUrl').value=easterEggClickImageUrl; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyInitialLoaderEasterEgg(); scheduleRender(true); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyTorrentListFontSize(torrentListFontSize); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }"; diff --git a/pytorrent/static/js/runtimeState.js b/pytorrent/static/js/runtimeState.js index 2c0a9ec..c6715f2 100644 --- a/pytorrent/static/js/runtimeState.js +++ b/pytorrent/static/js/runtimeState.js @@ -1 +1 @@ -export const runtimeStateSource = " let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Reverse DNS follow-up refreshes are independent from the user-selected peers auto-refresh interval.\n const REVERSE_DNS_REFRESH_SECONDS = 2;\n const REVERSE_DNS_REFRESH_MAX_ATTEMPTS = 8;\n let reverseDnsRefreshTimer = null;\n let reverseDnsRefreshInFlight = false;\n let reverseDnsRefreshAttempts = 0;\n let reverseDnsRefreshHash = null;\n let mobileReverseDnsRefreshTimer = null;\n let mobileReverseDnsRefreshAttempts = 0;\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let easterEggEnabled = Number(window.PYTORRENT?.easterEggEnabled || 0) !== 0;\n let easterEggLoadingImageUrl = String(window.PYTORRENT?.easterEggLoadingImageUrl || \"\").trim();\n let easterEggClickImageUrl = String(window.PYTORRENT?.easterEggClickImageUrl || \"\").trim();\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_PREFIX = \"pytorrent.footerStatus.v2\";\n function isActiveProfilePayload(payload={}){\n const payloadProfile = Number(payload?.profile_id || 0);\n const currentProfile = Number(activeProfileId || window.PYTORRENT?.activeProfile || 0);\n return !payloadProfile || !currentProfile || payloadProfile === currentProfile;\n }\n function currentProfileStorageId(){\n return String(activeProfileId || window.PYTORRENT?.activeProfile || \"none\");\n }\n function footerStatusStorageKey(profileId=currentProfileStorageId()){\n return `${FOOTER_STATUS_STORAGE_PREFIX}.${profileId || \"none\"}`;\n }\n function clearProfileScopedFooterState(){\n // Note: Profile changes clear footer-only values immediately so old rTorrent data is never mixed with the new profile.\n [\"statSockets\", \"statRtDownloads\", \"statRtUploads\", \"statRtHttp\", \"statRtFiles\", \"statRtPort\", \"statDl\", \"statUl\", \"mobileSpeedDl\", \"mobileSpeedUl\", \"statPeakSession\", \"statPeakAllTime\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n [\"statCpu\", \"statRam\", \"statVersion\", \"statTotalDl\", \"statTotalUl\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n lastBrowserSpeed.down = \"0 B/s\";\n lastBrowserSpeed.up = \"0 B/s\";\n updateBrowserSpeedTitle(\"0 B/s\", \"0 B/s\", 0, 0);\n }\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n"; +export const runtimeStateSource = " let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Reverse DNS follow-up refreshes are independent from the user-selected peers auto-refresh interval.\n const REVERSE_DNS_REFRESH_SECONDS = 2;\n const REVERSE_DNS_REFRESH_MAX_ATTEMPTS = 8;\n let reverseDnsRefreshTimer = null;\n let reverseDnsRefreshInFlight = false;\n let reverseDnsRefreshAttempts = 0;\n let reverseDnsRefreshHash = null;\n let mobileReverseDnsRefreshTimer = null;\n let mobileReverseDnsRefreshAttempts = 0;\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let easterEggEnabled = Number(window.PYTORRENT?.easterEggEnabled || 0) !== 0;\n let easterEggLoadingImageUrl = String(window.PYTORRENT?.easterEggLoadingImageUrl || \"\").trim();\n let easterEggClickImageUrl = String(window.PYTORRENT?.easterEggClickImageUrl || \"\").trim();\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let diskMonitorOwnerLabel = String(window.PYTORRENT?.diskMonitorOwnerLabel || \"\").trim();\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_PREFIX = \"pytorrent.footerStatus.v2\";\n function isActiveProfilePayload(payload={}){\n const payloadProfile = Number(payload?.profile_id || 0);\n const currentProfile = Number(activeProfileId || window.PYTORRENT?.activeProfile || 0);\n return !payloadProfile || !currentProfile || payloadProfile === currentProfile;\n }\n function currentProfileStorageId(){\n return String(activeProfileId || window.PYTORRENT?.activeProfile || \"none\");\n }\n function footerStatusStorageKey(profileId=currentProfileStorageId()){\n return `${FOOTER_STATUS_STORAGE_PREFIX}.${profileId || \"none\"}`;\n }\n function clearProfileScopedFooterState(){\n // Note: Profile changes clear footer-only values immediately so old rTorrent data is never mixed with the new profile.\n [\"statSockets\", \"statRtDownloads\", \"statRtUploads\", \"statRtHttp\", \"statRtFiles\", \"statRtPort\", \"statDl\", \"statUl\", \"mobileSpeedDl\", \"mobileSpeedUl\", \"statPeakSession\", \"statPeakAllTime\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n [\"statCpu\", \"statRam\", \"statVersion\", \"statTotalDl\", \"statTotalUl\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n lastBrowserSpeed.down = \"0 B/s\";\n lastBrowserSpeed.up = \"0 B/s\";\n updateBrowserSpeedTitle(\"0 B/s\", \"0 B/s\", 0, 0);\n }\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n"; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 197d7e3..6d14604 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -295,7 +295,7 @@
rTorrent profiles
Loading profiles...
Diagnostics
Run a profile test to show diagnostics.
Add profile
Create one rTorrent profile at a time. Move/remove queues keep their order for each profile.
Torrent statistics
Cached metadata summary. File metadata is refreshed every 15 minutes, a few minutes after startup, or manually.
Not loaded.
Open this tab to load statistics.
-
Appearance
Theme, typography and interface scale. Torrent view preferences also remember the selected filter, sorting and the height of the General / Files / Trackers panel.
View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.
Browser title
Controls what is shown in the browser tab.
Tracker icons
Visual helper for tracker filters in the sidebar.
Notifications
Toast notifications from automatic systems.
Disk monitor
Choose what the footer disk bar should represent and add extra storage paths.
Progress source
Monitored paths
The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.
Port checker
Incoming connection test, separate from visual preferences.
disabled
Uses YouGetSignal first. Manual check bypasses the 6h cache.
Peers
Optional peer table helpers.
Easter egg
Optional visual easter egg for loading states and occasional button clicks. Disabled by default.
Changes apply immediately where possible; initial startup loader uses them after reload.
+
Appearance
Theme, typography and interface scale. Torrent view preferences also remember the selected filter, sorting and the height of the General / Files / Trackers panel.
View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.
Browser title
Controls what is shown in the browser tab.
Tracker icons
Visual helper for tracker filters in the sidebar.
Notifications
Toast notifications from automatic systems.
Disk monitor
Choose what the footer disk bar should represent and add extra storage paths.
{% if prefs and prefs.disk_monitor_owner_label %}Owner: {{ prefs.disk_monitor_owner_label }}{% endif %}
Progress source
Monitored paths
The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.
Port checker
Incoming connection test, separate from visual preferences.
disabled
Uses YouGetSignal first. Manual check bypasses the 6h cache.
Peers
Optional peer table helpers.
Easter egg
Optional visual easter egg for loading states and occasional button clicks. Disabled by default.
Changes apply immediately where possible; initial startup loader uses them after reload.
Job scheduling
These settings are stored per active rTorrent profile. Light jobs are control actions such as start, stop, pause, resume, labels, ratio assignment, reannounce and speed limits. Heavy jobs are long or destructive actions such as move, remove and adding torrents.
Operation log retention
Manage operation log retention without changing torrent data.
Default log view
Controls the default category and job log visibility used by the Logs modal. Queued, retry, timeout and recovery events are included.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
@@ -407,7 +407,7 @@
- +