From fc7ca12a018f062fdb8140f6d5b2da25b273abe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 16 Jun 2026 13:47:39 +0200 Subject: [PATCH] fix localstorage and profile settings --- pytorrent/db.py | 9 ++++ pytorrent/migrations.py | 16 ++++++++ pytorrent/routes/torrents.py | 7 +++- pytorrent/services/profile_speed_limits.py | 48 ++++++++++++++++++++++ pytorrent/services/websocket.py | 26 +++++++++--- pytorrent/static/js/bootstrap.js | 2 +- pytorrent/static/js/preferencesTools.js | 2 +- pytorrent/static/js/sharedUi.js | 2 +- pytorrent/static/js/stateCore.js | 2 +- 9 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 pytorrent/services/profile_speed_limits.py diff --git a/pytorrent/db.py b/pytorrent/db.py index 0565bee..24df03f 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -372,6 +372,15 @@ CREATE TABLE IF NOT EXISTS traffic_history ( CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at); +CREATE TABLE IF NOT EXISTS profile_speed_limits ( + profile_id INTEGER PRIMARY KEY, + down_limit INTEGER DEFAULT 0, + up_limit INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS transfer_speed_peaks ( profile_id INTEGER PRIMARY KEY, session_started_at TEXT NOT NULL, diff --git a/pytorrent/migrations.py b/pytorrent/migrations.py index d9f0d23..0675d2a 100644 --- a/pytorrent/migrations.py +++ b/pytorrent/migrations.py @@ -133,10 +133,26 @@ def migrate_operation_log_split_retention(conn: sqlite3.Connection) -> bool: return changed +def migrate_profile_speed_limits_table(conn: sqlite3.Connection) -> bool: + existing = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='profile_speed_limits'").fetchone() + conn.execute(""" + CREATE TABLE IF NOT EXISTS profile_speed_limits ( + profile_id INTEGER PRIMARY KEY, + down_limit INTEGER DEFAULT 0, + up_limit INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE + ) + """) + return existing is None + + MIGRATIONS: tuple[Migration, ...] = ( migrate_disk_monitor_preferences_to_profile_scope, migrate_profile_preferences_sidebar_columns, migrate_operation_log_split_retention, + migrate_profile_speed_limits_table, ) diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index 02eecbd..e9d839e 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -1,6 +1,7 @@ from __future__ import annotations from ._shared import * +from ..services import profile_speed_limits from ..services import pdf_preview_links, torrent_creator from ..services.reverse_dns import attach_reverse_dns @@ -667,8 +668,10 @@ def speed_limits(): if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} - job_id = enqueue("set_limits", profile["id"], {"down": data.get("down"), "up": data.get("up")}) - return ok({"job_id": job_id}) + limits = profile_speed_limits.save_limits(profile["id"], data.get("down"), data.get("up")) + # Note: Manual speed limits are stored once per rTorrent profile, so every user opening this profile sees and applies the same values. + job_id = enqueue("set_limits", profile["id"], {"down": limits["down"], "up": limits["up"]}) + return ok({"job_id": job_id, "limits": limits}) def _user_disk_status(profile: dict) -> dict: diff --git a/pytorrent/services/profile_speed_limits.py b/pytorrent/services/profile_speed_limits.py new file mode 100644 index 0000000..b7883ca --- /dev/null +++ b/pytorrent/services/profile_speed_limits.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from ..db import connect, utcnow + + +def normalize_limit(value: object) -> int: + try: + limit = int(float(value or 0)) + except (TypeError, ValueError): + return 0 + return max(0, limit) + + +def get_limits(profile_id: int | None) -> dict: + profile_id = int(profile_id or 0) + if not profile_id: + return {"down": 0, "up": 0, "configured": False} + with connect() as conn: + row = conn.execute("SELECT down_limit, up_limit FROM profile_speed_limits WHERE profile_id=?", (profile_id,)).fetchone() + if not row: + return {"down": 0, "up": 0, "configured": False} + return {"down": int(row.get("down_limit") or 0), "up": int(row.get("up_limit") or 0), "configured": True} + + +def save_limits(profile_id: int, down: object, up: object) -> dict: + profile_id = int(profile_id or 0) + if not profile_id: + raise ValueError("Missing profile id") + clean = {"down": normalize_limit(down), "up": normalize_limit(up), "configured": True} + now = utcnow() + with connect() as conn: + conn.execute( + """ + INSERT INTO profile_speed_limits(profile_id, down_limit, up_limit, created_at, updated_at) + VALUES(?,?,?,?,?) + ON CONFLICT(profile_id) DO UPDATE SET + down_limit=excluded.down_limit, + up_limit=excluded.up_limit, + updated_at=excluded.updated_at + """, + (profile_id, clean["down"], clean["up"], now, now), + ) + return clean + + +def delete_limits(profile_id: int) -> None: + with connect() as conn: + conn.execute("DELETE FROM profile_speed_limits WHERE profile_id=?", (int(profile_id or 0),)) diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index e68f461..7dd9c8d 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -9,7 +9,7 @@ from .preferences import active_profile, get_profile from ..db import default_user_id from .torrent_cache import torrent_cache from .torrent_summary import cached_summary -from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner +from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks, poller_control, download_planner, profile_speed_limits def _profile_room(profile_id: int) -> str: @@ -37,6 +37,14 @@ def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None: +def _apply_configured_speed_limits(profile: dict) -> None: + limits = profile_speed_limits.get_limits(int(profile.get("id") or 0)) + if not limits.get("configured"): + return + # Note: Profile-level speed limits are re-applied when the profile is opened so they are not tied to a specific user session. + rtorrent.set_limits(profile, limits.get("down"), limits.get("up")) + + def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: state = poller_control.state_for(profile_id) # Note: Background checks keep the profile owner so bypass/admin profiles do not enqueue jobs as the fallback user. @@ -282,10 +290,14 @@ def register_socketio_handlers(socketio): if not profile: emit("profile_required", {"ok": True, "profiles": []}) return + try: + _apply_configured_speed_limits(profile) + except Exception as exc: + emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)}) rows = torrent_cache.snapshot(profile["id"]) emit("torrent_snapshot", {"profile_id": profile["id"], "torrents": rows, "summary": cached_summary(profile["id"], rows), "speed_status": _speed_status_from_rows(profile["id"], rows)}) - emit("poller_settings", {"settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))}) - emit("download_plan_update", {"settings": download_planner.get_settings(int(profile["id"]))}) + emit("poller_settings", {"profile_id": int(profile["id"]), "settings": poller_control.get_settings(int(profile["id"])), "runtime": poller_control.snapshot(int(profile["id"]))}) + emit("download_plan_update", {"profile_id": int(profile["id"]), "settings": download_planner.get_settings(int(profile["id"]))}) @socketio.on("select_profile") def handle_select_profile(data): @@ -304,8 +316,12 @@ def register_socketio_handlers(socketio): emit("rtorrent_error", {"error": "Profile access denied or profile does not exist"}) return join_room(_profile_room(profile_id)) + try: + _apply_configured_speed_limits(profile) + except Exception as exc: + emit("rtorrent_error", {"profile_id": profile_id, "error": str(exc)}) diff = torrent_cache.refresh(profile) rows = torrent_cache.snapshot(profile_id) emit("torrent_snapshot", {"profile_id": profile_id, "torrents": rows, "summary": cached_summary(profile_id, rows, force=True), "speed_status": _speed_status_from_rows(profile_id, rows), "error": diff.get("error", "")}) - emit("poller_settings", {"settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)}) - emit("download_plan_update", {"settings": download_planner.get_settings(profile_id)}) + emit("poller_settings", {"profile_id": profile_id, "settings": poller_control.get_settings(profile_id), "runtime": poller_control.snapshot(profile_id)}) + emit("download_plan_update", {"profile_id": profile_id, "settings": download_planner.get_settings(profile_id)}) diff --git a/pytorrent/static/js/bootstrap.js b/pytorrent/static/js/bootstrap.js index 995f3d1..84876b2 100644 --- a/pytorrent/static/js/bootstrap.js +++ b/pytorrent/static/js/bootstrap.js @@ -1 +1 @@ -export const bootstrapSource = " async function loadInitialSnapshotFallback(reason=''){\n if(initialLoaderDone) return;\n try{\n const profilesResp = await fetch('/api/profiles', {cache:'no-store'});\n const profilesJson = await profilesResp.json().catch(()=>({ok:false}));\n const active = profilesJson.active || null;\n if(!active){ showFirstRunSetup(); return; }\n const torrentsResp = await fetch('/api/torrents', {cache:'no-store'});\n const j = await torrentsResp.json().catch(()=>({ok:false,error:'Invalid /api/torrents response'}));\n if(j.ok === false) throw new Error(j.error || 'Torrent API failed');\n const rows = j.torrents || [];\n if(j.error && !rows.length){ renderRtorrentStartingState(j.error, true); hideInitialLoader(); return; }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = j.summary || null;\n torrents.clear();\n rows.forEach(t=>torrents.set(t.hash,t));\n if(j.speed_status) applyLiveSpeedStats(j.speed_status); else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(true);\n hideInitialLoader();\n }catch(e){\n setInitialLoader('Waiting for rTorrent...', (reason ? reason + ': ' : '') + (e.message || 'Unable to load torrent data.'));\n renderRtorrentStartingState(e.message || reason || 'Unable to load torrent data.', true);\n hideInitialLoader();\n }\n }\n async function refreshTorrentSnapshot(reason='') {\n if(!hasActiveProfile) return;\n try {\n const response = await fetch('/api/torrents', {cache:'no-store'});\n const data = await response.json().catch(() => ({ok:false,error:'Invalid /api/torrents response'}));\n if(data.ok === false) throw new Error(data.error || 'Torrent API failed');\n const rows = data.torrents || [];\n if(data.error && !rows.length) {\n renderRtorrentStartingState(data.error, true);\n return;\n }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = data.summary || null;\n torrents.clear();\n selected.clear();\n rows.forEach(t => torrents.set(t.hash, t));\n if(data.speed_status) applyLiveSpeedStats(data.speed_status);\n else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(false);\n } catch (error) {\n if(reason) recordNotification('error', 'Torrent refresh failed', error.message || reason);\n }\n }\n setTimeout(()=>loadInitialSnapshotFallback('Socket fallback'), 4000);\n if(!socket || !socket.io || typeof socket.on !== 'function') setTimeout(()=>loadInitialSnapshotFallback('Socket.IO unavailable'), 200);\n socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{const rows=msg.torrents||[]; if(msg.error && !rows.length){ renderRtorrentStartingState(msg.error, true); return; } clearRtorrentStartingState(); hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();rows.forEach(t=>torrents.set(t.hash,t));if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle();scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{patchRows(msg);scheduleTrackerSummary(false);}); socket.on('torrent_live_patch',msg=>{applyLiveTorrentStats(msg);}); socket.on('job_update',msg=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); if(['done','failed','cancelled'].includes(String(msg?.status||''))) setTimeout(()=>refreshTorrentSnapshot('job_update'), 350); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');if(shouldShowOperationToast(msg)) toastMessage('toast.operationStarted','secondary',{action:msg.action});}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);applyActionCompletionState(msg.action,msg.hashes||[]);refreshTorrentSnapshot('operation_finished');if(shouldShowOperationToast(msg)) toastMessage('toast.operationDone','success',{action:msg.action});}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);if(shouldShowOperationToast(msg)) toastMessage('toast.operationFailed','danger',{action:msg.action,error:msg.error});}); socket.on('rtorrent_error',msg=>{ if(msg.error){ recordNotification('error','rTorrent error',msg.error);$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} else if(socket.connected){clearRtorrentStartingState();$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg?.enabled && !msg.cooldown_skipped) recordNotification('queue','Smart Queue decision',smartQueueToastMessage(msg)); if(msg?.cooldown_remaining_seconds!==undefined) updateCooldownBadge('smartCooldownBadge', Number(msg.cooldown_remaining_seconds||0)); if(msg && msg.enabled && !msg.cooldown_skipped && smartQueueToastsEnabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.error) recordNotification('error','Automation error',msg.error); if(msg?.applied?.length) recordNotification('info','Automation applied',`${msg.applied.length} item(s)`); if(msg?.applied?.length && automationToastsEnabled) toastMessage('toast.automationsApplied','secondary',{count:msg.applied.length}); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toastMessage('toast.torrentStatsError','danger',{error:msg.error}); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toastMessage('toast.startupConfigApplied','success',{count:msg.result.updated.length}); if(msg?.error) toastMessage('toast.startupConfigFailed','danger',{error:msg.error}); }); socket.on('download_plan_update',msg=>{ if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed||msg.pause_reason)) recordNotification('planner','Planner action',`paused ${msg.paused||0}, resumed ${msg.resumed||0}${msg.pause_reason?`, ${msg.pause_reason}`:''}`); if(msg?.settings) fillPlanner(msg.settings); if(msg?.preview) renderPlannerPreview(msg.preview); else if(msg?.matched_rule) renderPlannerPreview(msg); if(msg?.history) renderPlannerHistory(msg.history); if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed)) toastMessage('toast.plannerSocketResult','secondary',{paused:msg.paused,resumed:msg.resumed,dryRun:msg.dry_run}); }); socket.on('poller_settings',msg=>fillPoller(msg?.settings||{},msg?.runtime||{}));\n function rtorrentPairText(current, max){\n if(current == null) return '-';\n return max == null ? String(current) : `${current}/${max}`;\n }\n function footerStatusUpdatedText(s={}){\n const value=s.footer_updated_at || s.updated_at;\n if(!value) return '';\n const date=new Date(value);\n return Number.isNaN(date.getTime()) ? '' : ` \u00b7 last known ${date.toLocaleString()}`;\n }\n function updateRtorrentFooterStats(s={}, cached=false){\n const suffix=cached ? footerStatusUpdatedText(s) : '';\n const sockets=rtorrentPairText(s.open_sockets, s.max_open_sockets);\n if($('statSockets')) $('statSockets').textContent=sockets;\n if($('statusSockets')) $('statusSockets').title=s.open_sockets == null ? `Open sockets unavailable${suffix}` : `Open rTorrent sockets${s.max_open_sockets == null ? '' : ' / max'}: ${sockets}${suffix}`;\n if($('statRtDownloads')) $('statRtDownloads').textContent=rtorrentPairText(s.active_downloads, s.max_downloads_global);\n if($('statusRtDownloads')) $('statusRtDownloads').title=`Active rTorrent downloads / max global downloads${suffix}`;\n if($('statRtUploads')) $('statRtUploads').textContent=rtorrentPairText(s.active_uploads, s.max_uploads_global);\n if($('statusRtUploads')) $('statusRtUploads').title=`Active rTorrent uploads / max global uploads${suffix}`;\n if($('statRtHttp')) $('statRtHttp').textContent=rtorrentPairText(s.open_http, s.max_open_http);\n if($('statusRtHttp')) $('statusRtHttp').title=`Open rTorrent HTTP connections / max HTTP connections${suffix}`;\n if($('statRtFiles')) $('statRtFiles').textContent=rtorrentPairText(s.open_files, s.max_open_files);\n if($('statusRtFiles')) $('statusRtFiles').title=`Open rTorrent files / max open files${suffix}`;\n if($('statRtPort')) $('statRtPort').textContent=(s.listen_port ?? '-') || '-';\n if($('statusRtPort')) $('statusRtPort').title=`rTorrent incoming port${suffix}`;\n if(cached){\n if(s.cpu!==undefined && $('statCpu')) $('statCpu').textContent=s.cpu;\n if(s.ram!==undefined && $('statRam')) $('statRam').textContent=s.ram;\n if(s.version!==undefined && $('statVersion')) $('statVersion').textContent=s.version || '-';\n if(s.down_rate_h!==undefined && $('statDl')) $('statDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('statUl')) $('statUl').textContent=s.up_rate_h || '0 B/s';\n if(s.down_rate_h!==undefined && $('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h || '0 B/s';\n updateBrowserSpeedTitle(s.down_rate_h, s.up_rate_h);\n }\n }\n function saveFooterStatusCache(s={}){\n const payload={\n open_sockets:s.open_sockets, max_open_sockets:s.max_open_sockets,\n active_downloads:s.active_downloads, max_downloads_global:s.max_downloads_global,\n active_uploads:s.active_uploads, max_uploads_global:s.max_uploads_global,\n open_http:s.open_http, max_open_http:s.max_open_http,\n open_files:s.open_files, max_open_files:s.max_open_files,\n listen_port:s.listen_port,\n cpu:s.cpu, ram:s.ram, version:s.version,\n down_rate_h:s.down_rate_h, up_rate_h:s.up_rate_h,\n footer_updated_at:new Date().toISOString()\n };\n try{ localStorage.setItem(FOOTER_STATUS_STORAGE_KEY, JSON.stringify(payload)); }catch(_){}\n }\n function restoreFooterStatusCache(){\n try{\n const cached=JSON.parse(localStorage.getItem(FOOTER_STATUS_STORAGE_KEY)||'null');\n if(cached && typeof cached==='object') updateRtorrentFooterStats(cached, true);\n }catch(_){}\n }\n async function refreshFooterStatusNow(){\n try{\n const res=await fetch('/api/system/status', {cache:'no-store'});\n const j=await res.json();\n const s=j.status||{};\n if(j.ok && s){\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n applyFooterPreferences();\n }\n }catch(_){}\n }\n socket.on('system_stats',s=>{\n const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined;\n $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);\n $('statRamBox')?.classList.toggle('d-none',!usageAvailable);\n $('systemChart')?.classList.toggle('d-none',!usageAvailable);\n if(usageAvailable){\n $('statCpu').textContent=s.cpu??'-';\n $('statRam').textContent=s.ram??'-';\n drawSystemUsage(s.cpu,s.ram);\n }\n $('statVersion').textContent=s.version||'-';\n applyLiveSpeedStats(s);\n lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};\n $('statDlLimit').textContent=s.down_limit_h||'\u221e';\n $('statUlLimit').textContent=s.up_limit_h||'\u221e';\n $('statTotalDl').textContent=compactTransferText(s.total_down_h);\n $('statTotalUl').textContent=compactTransferText(s.total_up_h);\n updateSpeedPeaks(s.speed_peaks||{});\n drawTraffic(s.down_rate,s.up_rate);\n if(diskMonitorMode==='default'){\n drawDiskUsage(s.disk);\n }else{\n refreshUserDiskUsage(false);\n }\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n if(s.poller) fillPoller(null,s.poller);\n applyFooterPreferences();\n });\n document.addEventListener('change',e=>{ const sort=e.target.closest('#mobileSortSelect'); if(sort){ setMobileSortValue(sort.value); return; } const sel=e.target.closest('#mobileFilterSelect'); if(!sel) return; setMobileFilterValue(sel.value); });\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 bootstrapSource = " async function loadInitialSnapshotFallback(reason=''){\n if(initialLoaderDone) return;\n try{\n const profilesResp = await fetch('/api/profiles', {cache:'no-store'});\n const profilesJson = await profilesResp.json().catch(()=>({ok:false}));\n const active = profilesJson.active || null;\n if(!active){ showFirstRunSetup(); return; }\n const torrentsResp = await fetch('/api/torrents', {cache:'no-store'});\n const j = await torrentsResp.json().catch(()=>({ok:false,error:'Invalid /api/torrents response'}));\n if(j.ok === false) throw new Error(j.error || 'Torrent API failed');\n const rows = j.torrents || [];\n if(j.error && !rows.length){ renderRtorrentStartingState(j.error, true); hideInitialLoader(); return; }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = j.summary || null;\n torrents.clear();\n rows.forEach(t=>torrents.set(t.hash,t));\n if(j.speed_status) applyLiveSpeedStats(j.speed_status); else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(true);\n hideInitialLoader();\n }catch(e){\n setInitialLoader('Waiting for rTorrent...', (reason ? reason + ': ' : '') + (e.message || 'Unable to load torrent data.'));\n renderRtorrentStartingState(e.message || reason || 'Unable to load torrent data.', true);\n hideInitialLoader();\n }\n }\n async function refreshTorrentSnapshot(reason='') {\n if(!hasActiveProfile) return;\n try {\n const response = await fetch('/api/torrents', {cache:'no-store'});\n const data = await response.json().catch(() => ({ok:false,error:'Invalid /api/torrents response'}));\n if(data.ok === false) throw new Error(data.error || 'Torrent API failed');\n const rows = data.torrents || [];\n if(data.error && !rows.length) {\n renderRtorrentStartingState(data.error, true);\n return;\n }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = data.summary || null;\n torrents.clear();\n selected.clear();\n rows.forEach(t => torrents.set(t.hash, t));\n if(data.speed_status) applyLiveSpeedStats(data.speed_status);\n else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(false);\n } catch (error) {\n if(reason) recordNotification('error', 'Torrent refresh failed', error.message || reason);\n }\n }\n setTimeout(()=>loadInitialSnapshotFallback('Socket fallback'), 4000);\n if(!socket || !socket.io || typeof socket.on !== 'function') setTimeout(()=>loadInitialSnapshotFallback('Socket.IO unavailable'), 200);\n socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{ if(!isActiveProfilePayload(msg)) return; const rows=msg.torrents||[]; if(msg.error && !rows.length){ renderRtorrentStartingState(msg.error, true); return; } clearRtorrentStartingState(); hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();rows.forEach(t=>torrents.set(t.hash,t));if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle();scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{ if(!isActiveProfilePayload(msg)) return; patchRows(msg);scheduleTrackerSummary(false);}); socket.on('torrent_live_patch',msg=>{ if(!isActiveProfilePayload(msg)) return; applyLiveTorrentStats(msg);}); socket.on('job_update',msg=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); if(['done','failed','cancelled'].includes(String(msg?.status||''))) setTimeout(()=>refreshTorrentSnapshot('job_update'), 350); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');if(shouldShowOperationToast(msg)) toastMessage('toast.operationStarted','secondary',{action:msg.action});}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);applyActionCompletionState(msg.action,msg.hashes||[]);refreshTorrentSnapshot('operation_finished');if(shouldShowOperationToast(msg)) toastMessage('toast.operationDone','success',{action:msg.action});}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);if(shouldShowOperationToast(msg)) toastMessage('toast.operationFailed','danger',{action:msg.action,error:msg.error});}); socket.on('rtorrent_error',msg=>{ if(!isActiveProfilePayload(msg)) return; if(msg.error){ recordNotification('error','rTorrent error',msg.error);$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} }); socket.on('heartbeat',msg=>{ if(!isActiveProfilePayload(msg)) return; if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} else if(socket.connected){clearRtorrentStartingState();$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg?.enabled && !msg.cooldown_skipped) recordNotification('queue','Smart Queue decision',smartQueueToastMessage(msg)); if(msg?.cooldown_remaining_seconds!==undefined) updateCooldownBadge('smartCooldownBadge', Number(msg.cooldown_remaining_seconds||0)); if(msg && msg.enabled && !msg.cooldown_skipped && smartQueueToastsEnabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.error) recordNotification('error','Automation error',msg.error); if(msg?.applied?.length) recordNotification('info','Automation applied',`${msg.applied.length} item(s)`); if(msg?.applied?.length && automationToastsEnabled) toastMessage('toast.automationsApplied','secondary',{count:msg.applied.length}); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toastMessage('toast.torrentStatsError','danger',{error:msg.error}); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toastMessage('toast.startupConfigApplied','success',{count:msg.result.updated.length}); if(msg?.error) toastMessage('toast.startupConfigFailed','danger',{error:msg.error}); }); socket.on('download_plan_update',msg=>{ if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed||msg.pause_reason)) recordNotification('planner','Planner action',`paused ${msg.paused||0}, resumed ${msg.resumed||0}${msg.pause_reason?`, ${msg.pause_reason}`:''}`); if(msg?.settings) fillPlanner(msg.settings); if(msg?.preview) renderPlannerPreview(msg.preview); else if(msg?.matched_rule) renderPlannerPreview(msg); if(msg?.history) renderPlannerHistory(msg.history); if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed)) toastMessage('toast.plannerSocketResult','secondary',{paused:msg.paused,resumed:msg.resumed,dryRun:msg.dry_run}); }); socket.on('poller_settings',msg=>{ if(!isActiveProfilePayload(msg)) return; fillPoller(msg?.settings||{},msg?.runtime||{}); });\n function rtorrentPairText(current, max){\n if(current == null) return '-';\n return max == null ? String(current) : `${current}/${max}`;\n }\n function footerStatusUpdatedText(s={}){\n const value=s.footer_updated_at || s.updated_at;\n if(!value) return '';\n const date=new Date(value);\n return Number.isNaN(date.getTime()) ? '' : ` · last known ${date.toLocaleString()}`;\n }\n function updateRtorrentFooterStats(s={}, cached=false){\n const suffix=cached ? footerStatusUpdatedText(s) : '';\n const sockets=rtorrentPairText(s.open_sockets, s.max_open_sockets);\n if($('statSockets')) $('statSockets').textContent=sockets;\n if($('statusSockets')) $('statusSockets').title=s.open_sockets == null ? `Open sockets unavailable${suffix}` : `Open rTorrent sockets${s.max_open_sockets == null ? '' : ' / max'}: ${sockets}${suffix}`;\n if($('statRtDownloads')) $('statRtDownloads').textContent=rtorrentPairText(s.active_downloads, s.max_downloads_global);\n if($('statusRtDownloads')) $('statusRtDownloads').title=`Active rTorrent downloads / max global downloads${suffix}`;\n if($('statRtUploads')) $('statRtUploads').textContent=rtorrentPairText(s.active_uploads, s.max_uploads_global);\n if($('statusRtUploads')) $('statusRtUploads').title=`Active rTorrent uploads / max global uploads${suffix}`;\n if($('statRtHttp')) $('statRtHttp').textContent=rtorrentPairText(s.open_http, s.max_open_http);\n if($('statusRtHttp')) $('statusRtHttp').title=`Open rTorrent HTTP connections / max HTTP connections${suffix}`;\n if($('statRtFiles')) $('statRtFiles').textContent=rtorrentPairText(s.open_files, s.max_open_files);\n if($('statusRtFiles')) $('statusRtFiles').title=`Open rTorrent files / max open files${suffix}`;\n if($('statRtPort')) $('statRtPort').textContent=(s.listen_port ?? '-') || '-';\n if($('statusRtPort')) $('statusRtPort').title=`rTorrent incoming port${suffix}`;\n if(cached){\n if(s.cpu!==undefined && $('statCpu')) $('statCpu').textContent=s.cpu;\n if(s.ram!==undefined && $('statRam')) $('statRam').textContent=s.ram;\n if(s.version!==undefined && $('statVersion')) $('statVersion').textContent=s.version || '-';\n if(s.down_rate_h!==undefined && $('statDl')) $('statDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('statUl')) $('statUl').textContent=s.up_rate_h || '0 B/s';\n if(s.down_rate_h!==undefined && $('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h || '0 B/s';\n updateBrowserSpeedTitle(s.down_rate_h, s.up_rate_h);\n }\n }\n function saveFooterStatusCache(s={}){\n const payload={\n open_sockets:s.open_sockets, max_open_sockets:s.max_open_sockets,\n active_downloads:s.active_downloads, max_downloads_global:s.max_downloads_global,\n active_uploads:s.active_uploads, max_uploads_global:s.max_uploads_global,\n open_http:s.open_http, max_open_http:s.max_open_http,\n open_files:s.open_files, max_open_files:s.max_open_files,\n listen_port:s.listen_port,\n cpu:s.cpu, ram:s.ram, version:s.version,\n down_rate_h:s.down_rate_h, up_rate_h:s.up_rate_h,\n footer_updated_at:new Date().toISOString()\n };\n try{ localStorage.setItem(FOOTER_STATUS_STORAGE_KEY, JSON.stringify(payload)); }catch(_){}\n }\n function restoreFooterStatusCache(){\n try{\n const cached=JSON.parse(localStorage.getItem(FOOTER_STATUS_STORAGE_KEY)||'null');\n if(cached && typeof cached==='object') updateRtorrentFooterStats(cached, true);\n }catch(_){}\n }\n async function refreshFooterStatusNow(){\n try{\n const res=await fetch('/api/system/status', {cache:'no-store'});\n const j=await res.json();\n const s=j.status||{};\n if(j.ok && s){\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n applyFooterPreferences();\n }\n }catch(_){}\n }\n socket.on('system_stats',s=>{\n const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined;\n $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);\n $('statRamBox')?.classList.toggle('d-none',!usageAvailable);\n $('systemChart')?.classList.toggle('d-none',!usageAvailable);\n if(usageAvailable){\n $('statCpu').textContent=s.cpu??'-';\n $('statRam').textContent=s.ram??'-';\n drawSystemUsage(s.cpu,s.ram);\n }\n $('statVersion').textContent=s.version||'-';\n applyLiveSpeedStats(s);\n lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};\n $('statDlLimit').textContent=s.down_limit_h||'∞';\n $('statUlLimit').textContent=s.up_limit_h||'∞';\n $('statTotalDl').textContent=compactTransferText(s.total_down_h);\n $('statTotalUl').textContent=compactTransferText(s.total_up_h);\n updateSpeedPeaks(s.speed_peaks||{});\n drawTraffic(s.down_rate,s.up_rate);\n if(diskMonitorMode==='default'){\n drawDiskUsage(s.disk);\n }else{\n refreshUserDiskUsage(false);\n }\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n if(s.poller) fillPoller(null,s.poller);\n applyFooterPreferences();\n });\n document.addEventListener('change',e=>{ const sort=e.target.closest('#mobileSortSelect'); if(sort){ setMobileSortValue(sort.value); return; } const sel=e.target.closest('#mobileFilterSelect'); if(!sel) return; setMobileFilterValue(sel.value); });\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"; diff --git a/pytorrent/static/js/preferencesTools.js b/pytorrent/static/js/preferencesTools.js index 58c4b00..75e137d 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 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); }"; +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 const nextFilter = String(prefs.active_filter || 'all');\n // Note: Profile switches refresh the in-memory filter/sort immediately, matching the state that a full page reload would load.\n activeTrackerFilter = nextFilter.startsWith('tracker:') ? nextFilter.slice(8) : '';\n activeFilter = nextFilter.startsWith('tracker:') ? 'all' : nextFilter;\n mobileActiveFilterKey = nextFilter || 'all';\n try{\n const nextSort = typeof prefs.torrent_sort_json === 'string' ? JSON.parse(prefs.torrent_sort_json || '{}') : (prefs.torrent_sort_json || {});\n if(SORT_KEYS.has(nextSort.key)) sortState = {key: nextSort.key, dir: Number(nextSort.dir) < 0 ? -1 : 1};\n }catch(_){}\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/sharedUi.js b/pytorrent/static/js/sharedUi.js index a9a33dc..6b98eaa 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 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},queued:{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},queued:{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(browserViewPrefsKey())||'{}')||{};\n localStorage.setItem(browserViewPrefsKey(), 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 try{ localStorage.removeItem(browserViewPrefsKey()); }catch(e){}\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},queued:{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},queued:{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 08e9473..fde2a47 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 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"; +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 function browserViewPrefsKey(){\n const profileId = String(window.PYTORRENT?.activeProfile || activeProfileId || 'none');\n return `pyTorrent.mobileViewPrefs.${profileId}`;\n }\n function loadBrowserViewPrefs(){\n try{\n const scoped = JSON.parse(localStorage.getItem(browserViewPrefsKey())||'{}')||{};\n if(Object.keys(scoped).length) return scoped;\n const legacy = JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n return String(window.PYTORRENT?.activeProfile || activeProfileId || 'none') === 'none' ? legacy : {};\n }catch(e){ return {}; }\n }\n function isActiveProfilePayload(payload){\n const payloadProfile = Number(payload?.profile_id || 0);\n const activeProfile = Number(window.PYTORRENT?.activeProfile || activeProfileId || 0);\n return !payloadProfile || !activeProfile || payloadProfile === activeProfile;\n }\n const browserViewPrefs = loadBrowserViewPrefs();\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";