diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py
index 8d4d177..489b958 100644
--- a/pytorrent/services/rtorrent/torrents.py
+++ b/pytorrent/services/rtorrent/torrents.py
@@ -197,8 +197,8 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict
except Exception:
pass
c.call("d.custom1.set", h, label_value)
- row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value})
- changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
+ row.update({"state": 0, "active": 0, "paused": False, "post_check": True, "status": "Post-check", "label": label_value})
+ changes.append({"hash": h, "action": "mark_post_check_waiting", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
_clear_post_check_watch(profile_id, h)
except Exception as exc:
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
@@ -267,8 +267,10 @@ def normalize_row(row: list) -> dict:
complete = int(row[3] or 0)
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
- is_paused = bool(state) and not bool(is_active) and not is_checking
- status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
+ post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
+ is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check
+ # Note: Post-check is an application-level state that separates torrents waiting after a recheck from manually stopped torrents.
+ status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
to_download_bytes = remaining_bytes if not complete else 0
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
return {
@@ -305,6 +307,7 @@ def normalize_row(row: list) -> dict:
"ratio_group": str(row[18] or ""),
"message": msg,
"status": status,
+ "post_check": post_check,
"hashing": hashing,
}
@@ -545,12 +548,16 @@ def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
active = _int_rpc(c, 'd.is_active', h)
opened = _int_rpc(c, 'd.is_open', h)
# Note: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
+ label = _str_rpc(c, 'd.custom1', h)
+ post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
return {
'state': state,
'open': opened,
'active': active,
- 'paused': bool(state and opened and not active),
+ 'paused': bool(state and opened and not active and not post_check),
'stopped': not bool(state),
+ 'post_check': post_check,
+ 'label': label,
'message': _str_rpc(c, 'd.message', h),
}
@@ -590,10 +597,14 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result = {'hash': h, 'before': before, 'commands': []}
- if before.get('stopped'):
+ if before.get('stopped') and not before.get('post_check'):
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
return result
try:
+ # Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
+ if before.get('post_check'):
+ clear_post_check_download_label(c, h, before.get('label'))
+ result['commands'].append('clear_post_check_label')
# Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched.
c.call('d.stop', h)
result['commands'].append('d.stop')
@@ -643,10 +654,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
result: dict = {'hash': h, 'before': before, 'commands': []}
if before.get('active'):
+ if before.get('post_check'):
+ clear_post_check_download_label(c, h, before.get('label'))
+ before = _download_runtime_state(c, h)
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result
- if before.get('paused') and not prefer_start:
+ if before.get('paused') and not prefer_start and not before.get('post_check'):
# Note: Manual Start keeps the clean pause-to-resume path. Do not classify every
# state=1/active=0 item as paused; after auto-check this can be only a transient
# open/inactive rTorrent state and needs d.open + d.start.
@@ -654,6 +668,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
resumed['mode'] = 'resume_paused'
return resumed
+ if before.get('post_check'):
+ try:
+ # Note: Post-check start first forces a clean stopped state, matching the manual Stop -> Start recovery path.
+ c.call('d.stop', h)
+ result['commands'].append('d.stop')
+ except Exception as exc:
+ result.setdefault('ignored_errors', []).append(f'd.stop: {exc}')
try:
c.call('d.open', h)
result['commands'].append('d.open')
@@ -670,7 +691,13 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
except Exception as exc2:
result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}')
result['ok'] = False
- result['after'] = _download_runtime_state(c, h)
+ after = _download_runtime_state(c, h)
+ if before.get('post_check') and after.get('active'):
+ # Note: The marker stays in place when start fails so the row remains visible in the Post-check filter.
+ clear_post_check_download_label(c, h, before.get('label'))
+ result['commands'].append('clear_post_check_label')
+ after = _download_runtime_state(c, h)
+ result['after'] = after
result['ok'] = result.get('ok', True)
return result
diff --git a/pytorrent/services/torrent_summary.py b/pytorrent/services/torrent_summary.py
index c3f52b8..8451b10 100644
--- a/pytorrent/services/torrent_summary.py
+++ b/pytorrent/services/torrent_summary.py
@@ -19,7 +19,7 @@ _ERROR_PATTERNS = (
"unreachable",
"denied",
)
-_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "stopped")
+_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped")
_summary_cache: dict[int, dict] = {}
_summary_lock = RLock()
@@ -55,9 +55,12 @@ def _matches(row: dict, summary_type: str) -> bool:
return checking
if summary_type == "error":
return _has_error(row)
+ if summary_type == "post_check":
+ # Note: Post-check is counted separately from Stopped so automation can target it safely.
+ return str(row.get("status") or "") == "Post-check" or bool(row.get("post_check"))
if summary_type == "stopped":
- # Note: Stopped count follows the UI filter exactly, so torrents being hash-checked do not inflate an empty Stopped list.
- return not checking and not bool(row.get("state"))
+ # Note: Stopped count follows the UI filter exactly and excludes app-level post-check waiting rows.
+ return not checking and not bool(row.get("state")) and str(row.get("status") or "") != "Post-check" and not bool(row.get("post_check"))
return False
diff --git a/pytorrent/static/js/dashboard.js b/pytorrent/static/js/dashboard.js
index b8d0a1b..2f17661 100644
--- a/pytorrent/static/js/dashboard.js
+++ b/pytorrent/static/js/dashboard.js
@@ -1 +1 @@
-export const dashboardSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentWarning(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const warning=torrentWarning(t);\n if(view==='smart:needs_attention') return !!warning || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentWarning(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>`${esc(t.name||'-')} ${esc(t.status||'-')} \u00b7 ${esc(t.size_h||'-')} \u00b7 DL ${esc(t.down_rate_h||'0 B/s')} \u00b7 Ratio ${esc(t.ratio??'-')} `).join('');\n return `${esc(title)} ${esc(rows.length)}
${esc(note)} ${sample||'No items. '}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent warning state.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`
${panes.map(p=>`${p[1]} `).join('')} ${panes.map(p=>``).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n box.innerHTML=`${SMART_VIEW_DEFS.map(([key,label,note])=>`${esc(label)} ${esc(note)} ${esc(rows.filter(t=>smartViewVisible(t,key)).length)} torrents `).join('')}
`;\n}\nfunction notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=` Notifications${count?` ${count} `:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=` Clear${esc(items.length)} saved event(s)
${items.map(x=>`
${esc(x.title)} ${esc(x.message)} ${esc(new Date(x.at).toLocaleString())}
`).join('')||'
No notifications yet. '}
`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\nfunction diagnosticsSection(title, cards){\n return ` ${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=` Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)} `;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n}\nfunction ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML=' Torrent health
Live health buckets calculated from the current torrent snapshot.
';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML=' Smart Views
One-click filters for common torrent maintenance tasks.
';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML=' Notification center
Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.
';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n";
+export const dashboardSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentWarning(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction isPostCheckTorrent(t){ return t.status === 'Post-check' || !!t.post_check; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !isPostCheckTorrent(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const warning=torrentWarning(t);\n if(view==='smart:needs_attention') return !!warning || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentWarning(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>`${esc(t.name||'-')} ${esc(t.status||'-')} \u00b7 ${esc(t.size_h||'-')} \u00b7 DL ${esc(t.down_rate_h||'0 B/s')} \u00b7 Ratio ${esc(t.ratio??'-')} `).join('');\n return `${esc(title)} ${esc(rows.length)}
${esc(note)} ${sample||'No items. '}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent warning state.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`${panes.map(p=>`${p[1]} `).join('')} ${panes.map(p=>``).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n box.innerHTML=`${SMART_VIEW_DEFS.map(([key,label,note])=>`${esc(label)} ${esc(note)} ${esc(rows.filter(t=>smartViewVisible(t,key)).length)} torrents `).join('')}
`;\n}\nfunction notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=` Notifications${count?` ${count} `:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=` Clear${esc(items.length)} saved event(s)
${items.map(x=>`
${esc(x.title)} ${esc(x.message)} ${esc(new Date(x.at).toLocaleString())}
`).join('')||'
No notifications yet. '}
`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\nfunction diagnosticsSection(title, cards){\n return ` ${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=` Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)} `;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n}\nfunction ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML=' Torrent health
Live health buckets calculated from the current torrent snapshot.
';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML=' Smart Views
One-click filters for common torrent maintenance tasks.
';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML=' Notification center
Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.
';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n";
diff --git a/pytorrent/static/js/mobile.js b/pytorrent/static/js/mobile.js
index dbdd64c..c9eb18c 100644
--- a/pytorrent/static/js/mobile.js
+++ b/pytorrent/static/js/mobile.js
@@ -1 +1 @@
-export const mobileSource = " // Note: Mobile-only filtering, sorting and card rendering lives here so torrent table code stays focused on desktop rows.\n function enabledMobileSortSteps(){\n const enabled = MOBILE_SORT_STEPS.filter(step => mobileSortFilters[mobileSortStepId(step)]);\n return enabled.length ? enabled : MOBILE_SORT_STEPS.filter(step => DEFAULT_MOBILE_SORT_FILTER_IDS.has(mobileSortStepId(step)));\n }\n\n function mobileSortDef(){\n const steps = enabledMobileSortSteps();\n return steps.find(x=>x.key===sortState.key && x.dir===sortState.dir) || steps.find(x=>x.key===sortState.key) || steps[0] || MOBILE_SORT_STEPS[0];\n }\n\n function mobileSortLabel(){ const def=mobileSortDef(); return `${def.label} ${def.dir>0?'↑':'↓'}`; }\n\n function mobileSortSelectOptions(){\n const defs = [['name','Name'], ...COLUMN_DEFS.map(([key,label]) => [key,label])];\n const seen = new Set();\n return defs.filter(([key]) => { if(seen.has(key)) return false; seen.add(key); return SORT_KEYS.has(key); });\n }\n\n function setMobileSortValue(value){\n const [key, dir] = String(value || 'name:1').split(':');\n if(!SORT_KEYS.has(key)) return;\n sortState = {key, dir: Number(dir) < 0 ? -1 : 1};\n saveTorrentSortPreference();\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n scheduleRender(true);\n }\n\n function cycleMobileSort(){\n const steps = enabledMobileSortSteps();\n const current=steps.findIndex(x=>x.key===sortState.key && x.dir===sortState.dir);\n const next=steps[(current+1) % steps.length] || mobileSortDef();\n sortState={key:next.key, dir:next.dir};\n saveTorrentSortPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function setMobileFilterValue(value){\n const key=String(value||'all');\n mobileActiveFilterKey=key;\n if(key.startsWith('tracker:')){\n activeTrackerFilter=key.slice(8);\n activeFilter='all';\n }else{\n activeTrackerFilter='';\n activeFilter=key || 'all';\n }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function mobileFilterDefs(){\n const arr=trackerScopedRows();\n const defs=Object.keys(FILTER_COUNT_IDS).filter(k=>k!=='moving').map(k=>[k,k==='all'?'All':k==='downloading'?'Downloading':k==='seeding'?'Seeding':k==='paused'?'Paused':k==='checking'?'Checking':k==='error'?'With error':'Stopped',filterSummaryBucket(k).count||0]);\n const movingCount=movingFilterCount();\n if(movingCount) defs.push(['moving','Moving',movingCount]);\n const counts=new Map();\n arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label']));\n defs.push(['tracker:','All trackers',torrents.size,'tracker']);\n const trackerOptions=new Map((trackerSummary.trackers||[]).map(t=>[t.domain,t]));\n // Note: Preserve the selected tracker option even when the cache has not returned it yet, so the mobile select does not jump to All trackers.\n if(activeTrackerFilter && !trackerOptions.has(activeTrackerFilter)) trackerOptions.set(activeTrackerFilter, {domain:activeTrackerFilter, count:arr.length});\n [...trackerOptions.values()].forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker']));\n if(mobileSmartFiltersEnabled) SMART_VIEW_DEFS.forEach(([key,label])=>defs.push([key,label,arr.filter(t=>smartViewVisible(t,key)).length,'smart']));\n return defs;\n }\n\n function renderMobileFilters(){\n const bar=$('mobileFilterBar');\n if(!bar) return;\n const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash));\n const someVisible=visibleRows.some(t=>selected.has(t.hash));\n const defs=mobileFilterDefs();\n const currentSelect=$('mobileFilterSelect');\n const focused=currentSelect && document.activeElement===currentSelect;\n const sortSig = enabledMobileSortSteps().map(mobileSortStepId).join('|');\n const sig=[focused ? 'focus' : activeFilter, activeTrackerFilter, sortState.key, sortState.dir, selected.size, allVisible ? 1 : 0, someVisible ? 1 : 0, mobileSmartFiltersEnabled ? 1 : 0, sortSig, defs.map(d=>`${d[0]}:${d[2]}`).join('|')].join('::');\n if(focused) return;\n if(sig===lastMobileFiltersSignature) return;\n lastMobileFiltersSignature=sig;\n const selectedMobileKey = activeTrackerFilter ? `tracker:${activeTrackerFilter}` : (mobileActiveFilterKey === 'tracker:' ? 'tracker:' : activeFilter);\n // Note: Select exactly one mobile option; \"All\" and \"All trackers\" share the same data filter but must not both render as selected.\n const opts=defs.map(([key,label,count,type])=>{ const selectedOpt = key === selectedMobileKey; return `${type==='label'?'Label: ':type==='tracker'?'Tracker: ':type==='smart'?'Smart: ':''}${esc(label)} (${count}) `; }).join('');\n const bulk=selected.size?` Label Move .torrent `:'';\n // Note: Mobile sorting stays as a single cycle button while the step list covers every sortable column.\n bar.innerHTML=` ${allVisible?'Unselect all':'Select all'} Clear ${bulk}${selected.size} selected
Filter${opts}
Sort: ${esc(mobileSortLabel())}
`;\n }\n\n function mobileColumnValue(t, key){\n if(key==='status') return statusBadge(t);\n if(key==='size') return `Size ${esc(t.size_h||'-')}`;\n if(key==='progress') return null;\n if(key==='down_rate') return `DL ${esc(t.down_rate_h||'-')}`;\n if(key==='up_rate') return `UL ${esc(t.up_rate_h||'-')}`;\n if(key==='eta') return `ETA ${esc(t.eta_h||'-')}`;\n if(key==='seeds') return `Seeds ${esc(t.seeds??0)}`;\n if(key==='peers') return `Peers ${esc(t.peers??0)}`;\n if(key==='ratio') return `Ratio ${esc(t.ratio??'-')}`;\n if(key==='label') return `Label ${esc(t.label||'-')}`;\n if(key==='ratio_group') return `Ratio group ${esc(t.ratio_group||'-')}`;\n if(key==='down_total') return `Downloaded ${esc(t.down_total_h||'-')}`;\n // Note: Complete torrents hide this mobile line because there is nothing left to download.\n if(key==='to_download') return t.to_download_h ? `To download ${esc(t.to_download_h)}` : null;\n if(key==='up_total') return `Uploaded ${esc(t.up_total_h||'-')}`;\n if(key==='created') return `Added ${esc(formatDateTime(t.created))}`;\n if(key==='priority') return `Priority ${esc(t.priority ?? '-')}`;\n if(key==='state') return `State ${esc(t.state ?? '-')}`;\n if(key==='active') return `Active ${esc(t.active ?? '-')}`;\n if(key==='complete') return `Complete ${esc(t.complete ?? '-')}`;\n if(key==='hashing') return `Hashing ${esc(t.hashing ?? 0)}`;\n if(key==='message') return `Message ${esc(t.message||'-')}`;\n if(key==='hash') return `Hash ${esc(t.hash||'-')}`;\n return null;\n }\n\n function mobileInfoLines(t){\n const primary=[], secondary=[];\n MOBILE_COLUMN_DEFS.forEach(([key])=>{\n if(!mobileColumns[key]) return;\n const value = mobileColumnValue(t, key);\n if(!value) return;\n if(['status','size','ratio','eta','seeds','peers'].includes(key)) primary.push(value);\n else if(key!=='path') secondary.push(value);\n });\n return {primary:primary.join(' · '), secondary:secondary.join(' · ')};\n }\n";
+export const mobileSource = " // Note: Mobile-only filtering, sorting and card rendering lives here so torrent table code stays focused on desktop rows.\n function enabledMobileSortSteps(){\n const enabled = MOBILE_SORT_STEPS.filter(step => mobileSortFilters[mobileSortStepId(step)]);\n return enabled.length ? enabled : MOBILE_SORT_STEPS.filter(step => DEFAULT_MOBILE_SORT_FILTER_IDS.has(mobileSortStepId(step)));\n }\n\n function mobileSortDef(){\n const steps = enabledMobileSortSteps();\n return steps.find(x=>x.key===sortState.key && x.dir===sortState.dir) || steps.find(x=>x.key===sortState.key) || steps[0] || MOBILE_SORT_STEPS[0];\n }\n\n function mobileSortLabel(){ const def=mobileSortDef(); return `${def.label} ${def.dir>0?'↑':'↓'}`; }\n\n function mobileSortSelectOptions(){\n const defs = [['name','Name'], ...COLUMN_DEFS.map(([key,label]) => [key,label])];\n const seen = new Set();\n return defs.filter(([key]) => { if(seen.has(key)) return false; seen.add(key); return SORT_KEYS.has(key); });\n }\n\n function setMobileSortValue(value){\n const [key, dir] = String(value || 'name:1').split(':');\n if(!SORT_KEYS.has(key)) return;\n sortState = {key, dir: Number(dir) < 0 ? -1 : 1};\n saveTorrentSortPreference();\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n scheduleRender(true);\n }\n\n function cycleMobileSort(){\n const steps = enabledMobileSortSteps();\n const current=steps.findIndex(x=>x.key===sortState.key && x.dir===sortState.dir);\n const next=steps[(current+1) % steps.length] || mobileSortDef();\n sortState={key:next.key, dir:next.dir};\n saveTorrentSortPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function setMobileFilterValue(value){\n const key=String(value||'all');\n mobileActiveFilterKey=key;\n if(key.startsWith('tracker:')){\n activeTrackerFilter=key.slice(8);\n activeFilter='all';\n }else{\n activeTrackerFilter='';\n activeFilter=key || 'all';\n }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function mobileFilterDefs(){\n const arr=trackerScopedRows();\n const defs=Object.keys(FILTER_COUNT_IDS).filter(k=>k!=='moving').map(k=>[k,k==='all'?'All':k==='downloading'?'Downloading':k==='seeding'?'Seeding':k==='paused'?'Paused':k==='checking'?'Checking':k==='error'?'With error':k==='post_check'?'Post-check':'Stopped',filterSummaryBucket(k).count||0]);\n const movingCount=movingFilterCount();\n if(movingCount) defs.push(['moving','Moving',movingCount]);\n const counts=new Map();\n arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label']));\n defs.push(['tracker:','All trackers',torrents.size,'tracker']);\n const trackerOptions=new Map((trackerSummary.trackers||[]).map(t=>[t.domain,t]));\n // Note: Preserve the selected tracker option even when the cache has not returned it yet, so the mobile select does not jump to All trackers.\n if(activeTrackerFilter && !trackerOptions.has(activeTrackerFilter)) trackerOptions.set(activeTrackerFilter, {domain:activeTrackerFilter, count:arr.length});\n [...trackerOptions.values()].forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker']));\n if(mobileSmartFiltersEnabled) SMART_VIEW_DEFS.forEach(([key,label])=>defs.push([key,label,arr.filter(t=>smartViewVisible(t,key)).length,'smart']));\n return defs;\n }\n\n function mobileRenderedHashes(){\n const list=$('mobileList');\n if(!list) return [];\n return [...list.querySelectorAll('.mobile-card[data-hash]')].map(card=>card.dataset.hash).filter(Boolean);\n }\n\n function mobileSelectionScopeHashes(){\n const rendered = mobileRenderedHashes();\n if(rendered.length) return rendered;\n return visibleRows.map(t=>t.hash).filter(Boolean);\n }\n\n function toggleMobileVisibleSelection(){\n const hashes = mobileSelectionScopeHashes();\n const allSelected = hashes.length > 0 && hashes.every(hash=>selected.has(hash));\n // Note: Mobile Select all follows the cards currently rendered in the mobile list, not the hidden desktop table window.\n hashes.forEach(hash=>allSelected ? selected.delete(hash) : selected.add(hash));\n selectedHash = selected.size ? [...selected][selected.size - 1] : null;\n lastSelectedHash = selectedHash;\n }\n\n function renderMobileFilters(selectionRows = visibleRows){\n const bar=$('mobileFilterBar');\n if(!bar) return;\n const scopeRows = Array.isArray(selectionRows) ? selectionRows : visibleRows;\n const allVisible=scopeRows.length>0 && scopeRows.every(t=>selected.has(t.hash));\n const someVisible=scopeRows.some(t=>selected.has(t.hash));\n const defs=mobileFilterDefs();\n const currentSelect=$('mobileFilterSelect');\n const focused=currentSelect && document.activeElement===currentSelect;\n const sortSig = enabledMobileSortSteps().map(mobileSortStepId).join('|');\n const sig=[focused ? 'focus' : activeFilter, activeTrackerFilter, sortState.key, sortState.dir, selected.size, allVisible ? 1 : 0, someVisible ? 1 : 0, mobileSmartFiltersEnabled ? 1 : 0, sortSig, defs.map(d=>`${d[0]}:${d[2]}`).join('|')].join('::');\n if(focused) return;\n if(sig===lastMobileFiltersSignature) return;\n lastMobileFiltersSignature=sig;\n const selectedMobileKey = activeTrackerFilter ? `tracker:${activeTrackerFilter}` : (mobileActiveFilterKey === 'tracker:' ? 'tracker:' : activeFilter);\n // Note: Select exactly one mobile option; \"All\" and \"All trackers\" share the same data filter but must not both render as selected.\n const opts=defs.map(([key,label,count,type])=>{ const selectedOpt = key === selectedMobileKey; return `${type==='label'?'Label: ':type==='tracker'?'Tracker: ':type==='smart'?'Smart: ':''}${esc(label)} (${count}) `; }).join('');\n const bulk=selected.size?` Label Move .torrent `:'';\n // Note: Mobile sorting stays as a single cycle button while the step list covers every sortable column.\n bar.innerHTML=` ${allVisible?'Unselect all':'Select all'} Clear ${bulk}${selected.size} selected
Filter${opts}
Sort: ${esc(mobileSortLabel())}
`;\n }\n\n function mobileColumnValue(t, key){\n if(key==='status') return statusBadge(t);\n if(key==='size') return `Size ${esc(t.size_h||'-')}`;\n if(key==='progress') return null;\n if(key==='down_rate') return `DL ${esc(t.down_rate_h||'-')}`;\n if(key==='up_rate') return `UL ${esc(t.up_rate_h||'-')}`;\n if(key==='eta') return `ETA ${esc(t.eta_h||'-')}`;\n if(key==='seeds') return `Seeds ${esc(t.seeds??0)}`;\n if(key==='peers') return `Peers ${esc(t.peers??0)}`;\n if(key==='ratio') return `Ratio ${esc(t.ratio??'-')}`;\n if(key==='label') return `Label ${esc(t.label||'-')}`;\n if(key==='ratio_group') return `Ratio group ${esc(t.ratio_group||'-')}`;\n if(key==='down_total') return `Downloaded ${esc(t.down_total_h||'-')}`;\n // Note: Complete torrents hide this mobile line because there is nothing left to download.\n if(key==='to_download') return t.to_download_h ? `To download ${esc(t.to_download_h)}` : null;\n if(key==='up_total') return `Uploaded ${esc(t.up_total_h||'-')}`;\n if(key==='created') return `Added ${esc(formatDateTime(t.created))}`;\n if(key==='priority') return `Priority ${esc(t.priority ?? '-')}`;\n if(key==='state') return `State ${esc(t.state ?? '-')}`;\n if(key==='active') return `Active ${esc(t.active ?? '-')}`;\n if(key==='complete') return `Complete ${esc(t.complete ?? '-')}`;\n if(key==='hashing') return `Hashing ${esc(t.hashing ?? 0)}`;\n if(key==='message') return `Message ${esc(t.message||'-')}`;\n if(key==='hash') return `Hash ${esc(t.hash||'-')}`;\n return null;\n }\n\n function mobileInfoLines(t){\n const primary=[], secondary=[];\n MOBILE_COLUMN_DEFS.forEach(([key])=>{\n if(!mobileColumns[key]) return;\n const value = mobileColumnValue(t, key);\n if(!value) return;\n if(['status','size','ratio','eta','seeds','peers'].includes(key)) primary.push(value);\n else if(key!=='path') secondary.push(value);\n });\n return {primary:primary.join(' · '), secondary:secondary.join(' · ')};\n }\n";
diff --git a/pytorrent/static/js/poller.js b/pytorrent/static/js/poller.js
index 213f7de..0982da0 100644
--- a/pytorrent/static/js/poller.js
+++ b/pytorrent/static/js/poller.js
@@ -1 +1 @@
-export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms \u00b7 gap ${esc(rt.last_tick_gap_ms||0)} ms \u00b7 effective ${esc(rt.effective_interval_seconds||0)}s \u00b7 min ${esc(rt.configured_min_interval_seconds||0)}s \u00b7 payload ${esc(fmtBytes(rt.emitted_payload_size||0))} \u00b7 rTorrent calls ${esc(rt.rtorrent_call_count||0)} \u00b7 skipped ${esc(rt.skipped_emissions||0)} \u00b7 mode ${esc(mode)} \u00b7 adaptive ${adaptive?'on':'off'} \u00b7 ok ${rt.last_ok?'yes':'no'} \u00b7 ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('backupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup',{name:$('backupName')?.value||'Manual backup'}); toast('Backup created','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Backup schedule saved','success'); loadBackup();}); $('backupManager')?.addEventListener('click',async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ if(!confirm('Restore this backup and replace current app settings?')) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download-link`).catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n";
+export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms · gap ${esc(rt.last_tick_gap_ms||0)} ms · effective ${esc(rt.effective_interval_seconds||0)}s · min ${esc(rt.configured_min_interval_seconds||0)}s · payload ${esc(fmtBytes(rt.emitted_payload_size||0))} · rTorrent calls ${esc(rt.rtorrent_call_count||0)} · skipped ${esc(rt.skipped_emissions||0)} · mode ${esc(mode)} · adaptive ${adaptive?'on':'off'} · ok ${rt.last_ok?'yes':'no'} · ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('backupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup',{name:$('backupName')?.value||'Manual backup'}); toast('Backup created','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Backup schedule saved','success'); loadBackup();}); $('backupManager')?.addEventListener('click',async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ if(!confirm('Restore this backup and replace current app settings?')) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ toggleMobileVisibleSelection(); scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download-link`).catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n";
diff --git a/pytorrent/static/js/torrents.js b/pytorrent/static/js/torrents.js
index d9f9f2d..4e40934 100644
--- a/pytorrent/static/js/torrents.js
+++ b/pytorrent/static/js/torrents.js
@@ -1 +1 @@
-export const torrentsSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='stopped') return !t.state && !isChecking(t);\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)} ${meta?`${esc(meta)} `:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentWarning(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)} `;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n box.innerHTML=labels.length?`Labels
${labels.map(l=>` ${esc(l)}${counts.get(l)} `).join('')}`:'';\n bindSidebarFilterClicks(box);\n }\n function trackerFavicon(tracker){\n const domain=typeof tracker==='string'?tracker:(tracker?.domain||'');\n if(!trackerFaviconsEnabled || !domain) return ' ';\n // Note: Normal rendering must use cached/static URLs only. Avoid refresh=1 here, otherwise scroll-triggered paints can re-warm icons repeatedly.\n const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}`;\n const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback;\n return ` `;\n }\n function trackerFilterPlaceholder(){\n if(trackerSummaryStatus==='loading') return ' Loading cached trackers...
';\n if(trackerSummaryStatus==='error') return ' Tracker list unavailable
';\n if(Number(trackerSummary.pending||0)) return ` Tracker cache: ${esc(trackerSummary.cached||0)}/${esc(trackerSummary.scanned||0)}
`;\n if(hasTorrentSnapshot && torrents.size) return 'No trackers found
';\n return 'Waiting for torrents...
';\n }\n function renderTrackerFilters(force=false){\n const box=$('trackerFilters');\n if(!box) return;\n const trackers=trackerSummary.trackers || [];\n // Note: Keep the selected tracker while the async summary is loading or temporarily incomplete; otherwise sorting can reset mobile scope to All trackers.\n if(activeTrackerFilter && trackerSummaryStatus==='ready' && trackers.length && !trackers.some(t=>t.domain===activeTrackerFilter)) activeTrackerFilter='';\n const sig=[\n trackerSummaryStatus,\n trackerFaviconsEnabled ? 1 : 0,\n trackerSummary.pending || 0,\n trackerSummary.cached || 0,\n trackerSummary.scanned || 0,\n trackers.map(t=>`${t.domain}:${t.count||0}:${t.favicon_url||''}`).join('|')\n ].join('::');\n if(!force && sig===lastTrackerFiltersSignature){ syncFilterButtons(); return; }\n lastTrackerFiltersSignature=sig;\n // Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.\n const rows=trackers.length\n ? ` All trackers${esc(torrents.size)} ` + trackers.map(t=>`${trackerFavicon(t)} ${esc(t.domain)} ${esc(t.count||0)} `).join('')\n : trackerFilterPlaceholder();\n box.innerHTML=`Trackers
${rows}`;\n bindSidebarFilterClicks(box);\n }\n async function refreshTrackerSummary(force=false){\n const hashes=[...torrents.keys()].sort();\n const sig=`${hashes.length}:${hashes[0]||''}:${hashes[hashes.length-1]||''}:${trackerFaviconsEnabled?1:0}`;\n if(!force && sig===trackerSummarySignature && !Number(trackerSummary.pending||0)) return;\n trackerSummarySignature=sig;\n if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; }\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'loading';\n renderTrackerFilters();\n try{\n // Note: Do not send 13k hashes in the URL; the backend uses a local snapshot and reads the cache in small chunks.\n const j=await (await fetch('/api/trackers/summary?scan_limit=0&warm=1&bg_limit=80')).json();\n if(!j.ok && !j.summary) throw new Error(j.error||'Tracker summary failed');\n trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0};\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':Number(trackerSummary.pending||0)?'empty':'empty';\n renderTrackerFilters();\n scheduleRender(true);\n if(Number(trackerSummary.pending||0)>0){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(true).catch(()=>{}), 5000);\n }\n }catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); }\n }\n function scheduleTrackerSummary(force=false){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(force).catch(()=>{}), force?50:600);\n }\n function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }\n function visibleColumnKeys(){ return ['select', ...COLUMN_DEFS.map(([key])=>key)].filter(key => key === 'select' || !hiddenColumns.has(key)); }\n function applyColumnWidths(){\n // Note: Widths are applied to headers and virtualized body rows, keeping all cells aligned after live renders.\n const table = document.querySelector('.torrent-table');\n if(!table) return;\n let total = 0;\n visibleColumnKeys().forEach(key => { total += columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120; });\n table.style.width = `${total}px`;\n table.style.minWidth = `${total}px`;\n document.querySelectorAll('.torrent-table [data-col]').forEach(el=>{\n const key = el.dataset.col;\n const width = columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120;\n el.style.width = `${width}px`;\n el.style.minWidth = `${width}px`;\n el.style.maxWidth = `${width}px`;\n });\n }\n function applyColumnVisibility(){\n document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col)));\n applyColumnWidths();\n }\n function saveColumnWidthsPreference(){\n saveBrowserViewPrefs({columnWidths});\n savePreferencePatch({table_columns_json:columnPrefsPayload()}, 300);\n }\n function setupColumnResizers(){\n document.querySelectorAll('.torrent-table thead th[data-col]').forEach(th=>{\n const key = th.dataset.col;\n if(!key || key === 'select' || th.querySelector('.column-resize-handle')) return;\n const handle = document.createElement('span');\n handle.className = 'column-resize-handle';\n handle.title = 'Drag to resize column';\n handle.setAttribute('aria-hidden', 'true');\n th.appendChild(handle);\n let startX = 0, startWidth = 0, dragged = false;\n const onMove = (event) => {\n dragged = true;\n columnWidths[key] = clampNumber(startWidth + event.clientX - startX, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, startWidth);\n applyColumnWidths();\n };\n const onUp = () => {\n document.body.classList.remove('resizing-columns');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n if(dragged) saveColumnWidthsPreference();\n };\n handle.addEventListener('pointerdown', event=>{\n event.preventDefault();\n event.stopPropagation();\n dragged = false;\n startX = event.clientX;\n startWidth = columnWidths[key] || th.getBoundingClientRect().width || DEFAULT_COLUMN_WIDTHS[key] || 120;\n document.body.classList.add('resizing-columns');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n handle.addEventListener('click', event=>event.stopPropagation());\n });\n }\n function syncActiveFilterSelection(){ syncFilterButtons(); }\n function actionLabel(action){\n const labels={start:'Starting',pause:'Pausing',stop:'Stopping',resume:'Resuming',recheck:'Checking',reannounce:'Reannouncing',remove:'Removing',move:'Moving',set_label:'Setting label',set_ratio_group:'Setting ratio'};\n return labels[action] || `Working: ${action}`;\n }\n function actionIcon(action){\n return ({start:'fa-play',pause:'fa-pause',stop:'fa-stop',resume:'fa-play',recheck:'fa-rotate',reannounce:'fa-bullhorn',remove:'fa-trash',move:'fa-folder-open',set_label:'fa-tag',set_ratio_group:'fa-scale-balanced'}[action]) || 'fa-gears';\n }\n function markTorrentOperation(hashes, action, jobId, state='queued'){\n const label=actionLabel(action);\n [...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));\n scheduleRender(true);\n }\n function markQueuedJobs(response, fallbackHashes, action){\n // Note: Supports API responses that split one large user action into multiple queued bulk parts.\n const jobs=Array.isArray(response?.jobs)?response.jobs:[];\n if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; }\n markTorrentOperation(fallbackHashes,action,response?.job_id,'queued');\n }\n function clearJobOperation(jobId, hashes=[]){\n if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }\n (hashes||[]).forEach(hash=>activeOperations.delete(hash));\n scheduleRender(true);\n }\n function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }\n function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return ` ${esc(m.label || t.status)} `; }\n function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }\n function torrentNameIcon(t){ const m=statusMeta(t); return ` `; }\n function boolCell(value){ return Number(value||0) ? 'yes ' : 'no '; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)} `).join(' ');\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ` `+\n `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)} `+\n `${statusBadge(t)} `+\n `${esc(t.size_h)} `+\n `${progress(t)} `+\n `${esc(t.down_rate_h)} `+\n `${esc(t.up_rate_h)} `+\n `${esc(t.eta_h||\"-\")} `+\n `${esc(t.seeds)} `+\n `${esc(t.peers)} `+\n `${esc(t.ratio)} `+\n `${esc(t.path)} `+\n `${labels||'- '} `+\n `${esc(t.ratio_group||'')} `+\n `${esc(t.down_total_h||'-')} `+\n `${esc(t.to_download_h||'-')} `+\n `${esc(t.up_total_h||'-')} `+\n `${esc(formatDateTime(t.created))} `+\n `${esc(t.priority ?? '-')} `+\n `${boolCell(t.state)} `+\n `${boolCell(t.active)} `+\n `${boolCell(t.complete)} `+\n `${esc(t.hashing ?? 0)} `+\n `${compactCell(t.message||'', 80)} `+\n `${esc(t.hash||'')} `+\n ` `;\n }\n\n\n\n\n function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); const lines=mobileInfoLines(t); return `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`; }).join('') || (hasTorrentSnapshot ? `No torrents.
` : loadingMarkup('Loading torrents...')); }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`No torrents for this filter. `:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/ROW_HEIGHT)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/ROW_HEIGHT)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*ROW_HEIGHT,bottom=Math.max(0,(visibleRows.length-end)*ROW_HEIGHT); body.innerHTML=(top?` `:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?` `:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n const show=selected.size>1;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n";
+export const torrentsSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='post_check') return t.status==='Post-check' || !!t.post_check;\n if(type==='stopped') return !t.state && !isChecking(t) && t.status!=='Post-check' && !t.post_check;\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)} ${meta?`${esc(meta)} `:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentWarning(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)} `;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n box.innerHTML=labels.length?`Labels
${labels.map(l=>` ${esc(l)}${counts.get(l)} `).join('')}`:'';\n bindSidebarFilterClicks(box);\n }\n function trackerFavicon(tracker){\n const domain=typeof tracker==='string'?tracker:(tracker?.domain||'');\n if(!trackerFaviconsEnabled || !domain) return ' ';\n // Note: Normal rendering must use cached/static URLs only. Avoid refresh=1 here, otherwise scroll-triggered paints can re-warm icons repeatedly.\n const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}`;\n const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback;\n return ` `;\n }\n function trackerFilterPlaceholder(){\n if(trackerSummaryStatus==='loading') return ' Loading cached trackers...
';\n if(trackerSummaryStatus==='error') return ' Tracker list unavailable
';\n if(Number(trackerSummary.pending||0)) return ` Tracker cache: ${esc(trackerSummary.cached||0)}/${esc(trackerSummary.scanned||0)}
`;\n if(hasTorrentSnapshot && torrents.size) return 'No trackers found
';\n return 'Waiting for torrents...
';\n }\n function renderTrackerFilters(force=false){\n const box=$('trackerFilters');\n if(!box) return;\n const trackers=trackerSummary.trackers || [];\n // Note: Keep the selected tracker while the async summary is loading or temporarily incomplete; otherwise sorting can reset mobile scope to All trackers.\n if(activeTrackerFilter && trackerSummaryStatus==='ready' && trackers.length && !trackers.some(t=>t.domain===activeTrackerFilter)) activeTrackerFilter='';\n const sig=[\n trackerSummaryStatus,\n trackerFaviconsEnabled ? 1 : 0,\n trackerSummary.pending || 0,\n trackerSummary.cached || 0,\n trackerSummary.scanned || 0,\n trackers.map(t=>`${t.domain}:${t.count||0}:${t.favicon_url||''}`).join('|')\n ].join('::');\n if(!force && sig===lastTrackerFiltersSignature){ syncFilterButtons(); return; }\n lastTrackerFiltersSignature=sig;\n // Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.\n const rows=trackers.length\n ? ` All trackers${esc(torrents.size)} ` + trackers.map(t=>`${trackerFavicon(t)} ${esc(t.domain)} ${esc(t.count||0)} `).join('')\n : trackerFilterPlaceholder();\n box.innerHTML=`Trackers
${rows}`;\n bindSidebarFilterClicks(box);\n }\n async function refreshTrackerSummary(force=false){\n const hashes=[...torrents.keys()].sort();\n const sig=`${hashes.length}:${hashes[0]||''}:${hashes[hashes.length-1]||''}:${trackerFaviconsEnabled?1:0}`;\n if(!force && sig===trackerSummarySignature && !Number(trackerSummary.pending||0)) return;\n trackerSummarySignature=sig;\n if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; }\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'loading';\n renderTrackerFilters();\n try{\n // Note: Do not send 13k hashes in the URL; the backend uses a local snapshot and reads the cache in small chunks.\n const j=await (await fetch('/api/trackers/summary?scan_limit=0&warm=1&bg_limit=80')).json();\n if(!j.ok && !j.summary) throw new Error(j.error||'Tracker summary failed');\n trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0};\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':Number(trackerSummary.pending||0)?'empty':'empty';\n renderTrackerFilters();\n scheduleRender(true);\n if(Number(trackerSummary.pending||0)>0){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(true).catch(()=>{}), 5000);\n }\n }catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); }\n }\n function scheduleTrackerSummary(force=false){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(force).catch(()=>{}), force?50:600);\n }\n function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }\n function visibleColumnKeys(){ return ['select', ...COLUMN_DEFS.map(([key])=>key)].filter(key => key === 'select' || !hiddenColumns.has(key)); }\n function applyColumnWidths(){\n // Note: Widths are applied to headers and virtualized body rows, keeping all cells aligned after live renders.\n const table = document.querySelector('.torrent-table');\n if(!table) return;\n let total = 0;\n visibleColumnKeys().forEach(key => { total += columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120; });\n table.style.width = `${total}px`;\n table.style.minWidth = `${total}px`;\n document.querySelectorAll('.torrent-table [data-col]').forEach(el=>{\n const key = el.dataset.col;\n const width = columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120;\n el.style.width = `${width}px`;\n el.style.minWidth = `${width}px`;\n el.style.maxWidth = `${width}px`;\n });\n }\n function applyColumnVisibility(){\n document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col)));\n applyColumnWidths();\n }\n function saveColumnWidthsPreference(){\n saveBrowserViewPrefs({columnWidths});\n savePreferencePatch({table_columns_json:columnPrefsPayload()}, 300);\n }\n function setupColumnResizers(){\n document.querySelectorAll('.torrent-table thead th[data-col]').forEach(th=>{\n const key = th.dataset.col;\n if(!key || key === 'select' || th.querySelector('.column-resize-handle')) return;\n const handle = document.createElement('span');\n handle.className = 'column-resize-handle';\n handle.title = 'Drag to resize column';\n handle.setAttribute('aria-hidden', 'true');\n th.appendChild(handle);\n let startX = 0, startWidth = 0, dragged = false;\n const onMove = (event) => {\n dragged = true;\n columnWidths[key] = clampNumber(startWidth + event.clientX - startX, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, startWidth);\n applyColumnWidths();\n };\n const onUp = () => {\n document.body.classList.remove('resizing-columns');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n if(dragged) saveColumnWidthsPreference();\n };\n handle.addEventListener('pointerdown', event=>{\n event.preventDefault();\n event.stopPropagation();\n dragged = false;\n startX = event.clientX;\n startWidth = columnWidths[key] || th.getBoundingClientRect().width || DEFAULT_COLUMN_WIDTHS[key] || 120;\n document.body.classList.add('resizing-columns');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n handle.addEventListener('click', event=>event.stopPropagation());\n });\n }\n function syncActiveFilterSelection(){ syncFilterButtons(); }\n function actionLabel(action){\n const labels={start:'Starting',pause:'Pausing',stop:'Stopping',resume:'Resuming',recheck:'Checking',reannounce:'Reannouncing',remove:'Removing',move:'Moving',set_label:'Setting label',set_ratio_group:'Setting ratio'};\n return labels[action] || `Working: ${action}`;\n }\n function actionIcon(action){\n return ({start:'fa-play',pause:'fa-pause',stop:'fa-stop',resume:'fa-play',recheck:'fa-rotate',reannounce:'fa-bullhorn',remove:'fa-trash',move:'fa-folder-open',set_label:'fa-tag',set_ratio_group:'fa-scale-balanced'}[action]) || 'fa-gears';\n }\n function markTorrentOperation(hashes, action, jobId, state='queued'){\n const label=actionLabel(action);\n [...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));\n scheduleRender(true);\n }\n function markQueuedJobs(response, fallbackHashes, action){\n // Note: Supports API responses that split one large user action into multiple queued bulk parts.\n const jobs=Array.isArray(response?.jobs)?response.jobs:[];\n if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; }\n markTorrentOperation(fallbackHashes,action,response?.job_id,'queued');\n }\n function clearJobOperation(jobId, hashes=[]){\n if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }\n (hashes||[]).forEach(hash=>activeOperations.delete(hash));\n scheduleRender(true);\n }\n function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }\n function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return ` ${esc(m.label || t.status)} `; }\n function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }\n function torrentNameIcon(t){ const m=statusMeta(t); return ` `; }\n function boolCell(value){ return Number(value||0) ? 'yes ' : 'no '; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)} `).join(' ');\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ` `+\n `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)} `+\n `${statusBadge(t)} `+\n `${esc(t.size_h)} `+\n `${progress(t)} `+\n `${esc(t.down_rate_h)} `+\n `${esc(t.up_rate_h)} `+\n `${esc(t.eta_h||\"-\")} `+\n `${esc(t.seeds)} `+\n `${esc(t.peers)} `+\n `${esc(t.ratio)} `+\n `${esc(t.path)} `+\n `${labels||'- '} `+\n `${esc(t.ratio_group||'')} `+\n `${esc(t.down_total_h||'-')} `+\n `${esc(t.to_download_h||'-')} `+\n `${esc(t.up_total_h||'-')} `+\n `${esc(formatDateTime(t.created))} `+\n `${esc(t.priority ?? '-')} `+\n `${boolCell(t.state)} `+\n `${boolCell(t.active)} `+\n `${boolCell(t.complete)} `+\n `${esc(t.hashing ?? 0)} `+\n `${compactCell(t.message||'', 80)} `+\n `${esc(t.hash||'')} `+\n ` `;\n }\n\n\n\n\n function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(rows); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); const lines=mobileInfoLines(t); return `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`; }).join('') || (hasTorrentSnapshot ? `No torrents.
` : loadingMarkup('Loading torrents...')); }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`No torrents for this filter. `:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/ROW_HEIGHT)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/ROW_HEIGHT)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*ROW_HEIGHT,bottom=Math.max(0,(visibleRows.length-end)*ROW_HEIGHT); body.innerHTML=(top?` `:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?` `:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n const show=selected.size>1;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n";
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html
index d3bbe07..1069b37 100644
--- a/pytorrent/templates/index.html
+++ b/pytorrent/templates/index.html
@@ -60,6 +60,7 @@
Paused 0
Checking 0
With error 0
+ Post-check 0
Stopped 0
Moving 0
@@ -297,7 +298,7 @@
-
+