diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index e90e0bc..3af6b65 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -198,12 +198,17 @@ def cleanup_jobs(): @bp.post("/cleanup/smart-queue") def cleanup_smart_queue(): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + profile_id = int(profile["id"]) with connect() as conn: exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone() if not exists: deleted = 0 else: - cur = conn.execute("DELETE FROM smart_queue_history") + # Note: Cleanup is limited to the active profile so read/write permissions never affect other profiles. + cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (profile_id,)) deleted = int(cur.rowcount or 0) return ok({"deleted": deleted, "cleanup": cleanup_summary()}) @@ -232,13 +237,17 @@ def cleanup_planner(): @bp.post("/cleanup/automations") def cleanup_automations(): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + profile_id = int(profile["id"]) with connect() as conn: exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() if not exists: deleted = 0 else: - # Note: Cleanup panel removes only automation logs, not saved automation rules. - cur = conn.execute("DELETE FROM automation_history") + # Note: Cleanup panel removes only active profile automation logs, not saved automation rules. + cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (profile_id,)) deleted = int(cur.rowcount or 0) return ok({"deleted": deleted, "cleanup": cleanup_summary()}) @@ -256,13 +265,13 @@ def cleanup_all(): if not exists: deleted_smart = 0 else: - cur = conn.execute("DELETE FROM smart_queue_history") + cur = conn.execute("DELETE FROM smart_queue_history WHERE profile_id=?", (active_profile_id,)) deleted_smart = int(cur.rowcount or 0) exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone() if not exists_auto: deleted_auto = 0 else: - cur = conn.execute("DELETE FROM automation_history") + cur = conn.execute("DELETE FROM automation_history WHERE profile_id=?", (active_profile_id,)) deleted_auto = int(cur.rowcount or 0) return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "operation_logs": deleted_logs, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()}) diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py index 9d4ecb0..b04ed65 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -35,6 +35,7 @@ RTORRENT_WRITE_PREFIXES = ( "/api/poller/settings", "/api/operation-logs", "/api/jobs", + "/api/cleanup", ) RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",) ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles") diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index 0bffc50..ad4739d 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -344,8 +344,9 @@ def save_auto_backup_settings(data: dict, user_id: int | None = None, backup_typ if backup_type == "app": _require_admin(user_id) else: - if not profile_id or not auth.can_access_profile(int(profile_id), user_id): - raise PermissionError("No access to profile") + # Note: Profile backup schedules affect profile operations, so read-only users may view/export backups but cannot change automation. + if not profile_id or not auth.can_write_profile(int(profile_id), user_id): + raise PermissionError("No write access to profile") current = get_auto_backup_settings(user_id, backup_type, profile_id) settings = { **current, diff --git a/pytorrent/static/js/authUsers.js b/pytorrent/static/js/authUsers.js index a7e1acd..9ff7b70 100644 --- a/pytorrent/static/js/authUsers.js +++ b/pytorrent/static/js/authUsers.js @@ -1,2 +1,2 @@ // User management stays separate from Smart Queue logic. -export const authUsersSource = " function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=`
New API tokenThis token is shown once. Copy it now before refreshing the page.
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never';\n return `
${esc(t.name||'API token')}${esc(t.token_prefix||'')} · created ${humanDateCell(t.created_at)} · last used ${last}
`;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=`
API tokensActive tokens for this user. Secrets are never shown after creation.
${tokens.length ? tokens.map(t=>tokenRow(t,userId)).join('') : '
No API tokens.
'}`;\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n box.querySelectorAll('.auth-token-delete').forEach(btn=>btn.addEventListener('click',async()=>{ if(!confirm('Delete this API token?')) return; await deleteAuthToken(btn.dataset.userId, btn.dataset.tokenId); await showAuthTokens(btn.dataset.userId); }));\n }catch(e){ toast(e.message,'danger'); }\n }\n async function deleteAuthToken(userId, tokenId){\n // Note: Token revocation must fail loudly when the token is already gone.\n const j=await post(`/api/auth/users/${userId}/tokens/${tokenId}`, {}, 'DELETE');\n toast('API token deleted','success');\n await loadAuthUsers();\n return j;\n }\n function renderAuthProviderInfo(auth={}){\n const box=$('authProviderInfo');\n if(!box) return;\n const provider=auth.provider || window.PYTORRENT.authProvider || 'local';\n const isTinyAuth=provider==='tinyauth';\n const isExternal=!!auth.external || isTinyAuth || provider==='proxy';\n $('authPassword')?.classList.toggle('d-none', isTinyAuth);\n if(!isExternal){ box.classList.add('d-none'); box.innerHTML=''; return; }\n const permission=auth.auto_create_role==='admin' ? 'admin: all profiles with full access' : (auth.auto_create_permission==='none' ? 'user: no profile access' : `user: ${auth.auto_create_permission==='full'?'Full':'R/O'} on all profiles`);\n const lines=[\n `Authentication provider: ${provider==='tinyauth'?'TinyAuth':'proxy header'}`,\n auth.auto_create ? `Auto-created users get ${permission}.` : 'Automatic user creation is disabled.',\n auth.bypass_enabled ? `Auth bypass enabled for ${auth.bypass_hosts?.length ? auth.bypass_hosts.join(', ') : 'configured hosts'} as ${auth.bypass_user || 'admin'}.` : 'Auth bypass is disabled.',\n isTinyAuth ? 'TinyAuth manages passwords; pyTorrent password changes are hidden.' : ''\n ].filter(Boolean);\n box.classList.remove('d-none');\n box.innerHTML=`
External authentication
`;\n }\n async function loadAuthUsers(){\n if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;\n const [usersRes, profilesRes]=await Promise.all([fetch('/api/auth/users'), fetch('/api/profiles')]);\n const usersJson=await usersRes.json();\n const profilesJson=await profilesRes.json();\n const profiles=profilesJson.profiles||[];\n const authInfo=usersJson.auth||{};\n renderAuthProviderInfo(authInfo);\n if($('authProfile')) $('authProfile').innerHTML=``+profiles.map(p=>``).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile '+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=`
`;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),``,actions];\n });\n $('authUsersManager').innerHTML=rows.length?responsiveTable(['User','Role','Active','Profile rights','API tokens','Actions'],rows,'auth-users-table'):'
No users.
';\n }\n async function generateAuthToken(userId){\n const name=prompt('Token name', 'API token');\n if(name===null) return;\n try{\n const j=await post(`/api/auth/users/${userId}/tokens`, {name:name||'API token'});\n const token=j.token?.token||'';\n renderGeneratedToken(token);\n await copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy the API token from the Users panel','warning'));\n await loadAuthUsers();\n }catch(e){ toast(e.message,'danger'); }\n }\n function resetAuthUserForm(){ ['authUserId','authUsername','authPassword'].forEach(id=>{ if($(id)) $(id).value=''; }); if($('authRole')) $('authRole').value='user'; if($('authProfile')) $('authProfile').value='0'; if($('authAccess')) $('authAccess').value='ro'; if($('authActive')) $('authActive').checked=true; $('authUserCancelBtn')?.classList.add('d-none'); }\n function editAuthUser(user){ if(!user) return; if($('authUserId')) $('authUserId').value=user.id||''; if($('authUsername')) $('authUsername').value=user.username||''; if($('authPassword')) $('authPassword').value=''; if($('authRole')) $('authRole').value=user.role||'user'; if($('authActive')) $('authActive').checked=!!user.is_active; const perm=(user.permissions||[])[0]||{profile_id:0,access_level:'ro'}; if($('authProfile')) $('authProfile').value=String(perm.profile_id||0); if($('authAccess')) $('authAccess').value=perm.access_level||'ro'; $('authUserCancelBtn')?.classList.remove('d-none'); }\n async function saveAuthUser(){\n const id=$('authUserId')?.value||'';\n const role=$('authRole')?.value||'user';\n const payload={username:$('authUsername')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\n if(!$('authPassword')?.classList.contains('d-none')) payload.password=$('authPassword')?.value||'';\n // Note: TinyAuth keeps passwords outside pyTorrent, so the hidden field is never submitted.\n try{ await post(id?`/api/auth/users/${id}`:'/api/auth/users',payload,id?'PUT':'POST'); toast('User saved','success'); resetAuthUserForm(); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); }\n }\n"; +export const authUsersSource = " function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=`
New API tokenThis token is shown once. Copy it now before refreshing the page.
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never';\n return `
${esc(t.name||'API token')}${esc(t.token_prefix||'')} · created ${humanDateCell(t.created_at)} · last used ${last}
`;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=`
API tokensActive tokens for this user. Secrets are never shown after creation.
${tokens.length ? tokens.map(t=>tokenRow(t,userId)).join('') : '
No API tokens.
'}`;\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n box.querySelectorAll('.auth-token-delete').forEach(btn=>btn.addEventListener('click',async()=>{ if(!confirm('Delete this API token?')) return; await deleteAuthToken(btn.dataset.userId, btn.dataset.tokenId); await showAuthTokens(btn.dataset.userId); }));\n }catch(e){ toast(e.message,'danger'); }\n }\n async function deleteAuthToken(userId, tokenId){\n // Note: Token revocation must fail loudly when the token is already gone.\n const j=await post(`/api/auth/users/${userId}/tokens/${tokenId}`, {}, 'DELETE');\n toast('API token deleted','success');\n await loadAuthUsers();\n return j;\n }\n function renderAuthProviderInfo(auth={}){\n const box=$('authProviderInfo');\n if(!box) return;\n const provider=auth.provider || window.PYTORRENT.authProvider || 'local';\n const isTinyAuth=provider==='tinyauth';\n const isExternal=!!auth.external || isTinyAuth || provider==='proxy';\n $('authPassword')?.classList.toggle('d-none', isTinyAuth);\n if(!isExternal){ box.classList.add('d-none'); box.innerHTML=''; return; }\n const permission=auth.auto_create_role==='admin' ? 'admin: all profiles with full access' : (auth.auto_create_permission==='none' ? 'user: no profile access' : `user: ${auth.auto_create_permission==='full'?'Full':'R/O'} on all profiles`);\n const lines=[\n `Authentication provider: ${provider==='tinyauth'?'TinyAuth':'proxy header'}`,\n auth.auto_create ? `Auto-created users get ${permission}.` : 'Automatic user creation is disabled.',\n auth.bypass_enabled ? `Auth bypass enabled for ${auth.bypass_hosts?.length ? auth.bypass_hosts.join(', ') : 'configured hosts'} as ${auth.bypass_user || 'admin'}.` : 'Auth bypass is disabled.',\n isTinyAuth ? 'TinyAuth manages passwords; pyTorrent password changes are hidden.' : ''\n ].filter(Boolean);\n box.classList.remove('d-none');\n box.innerHTML=`
External authentication
`;\n }\n async function loadAuthUsers(){\n if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;\n const [usersRes, profilesRes]=await Promise.all([fetch('/api/auth/users'), fetch('/api/profiles')]);\n const usersJson=await usersRes.json();\n const profilesJson=await profilesRes.json();\n const profiles=profilesJson.profiles||[];\n const authInfo=usersJson.auth||{};\n renderAuthProviderInfo(authInfo);\n if($('authProfile')) $('authProfile').innerHTML=``+profiles.map(p=>``).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile '+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=`
`;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),``,actions];\n });\n $('authUsersManager').innerHTML=rows.length?responsiveTable(['User','Role','Active','Profile rights','API tokens','Actions'],rows,'auth-users-table'):'
No users.
';\n }\n async function generateAuthToken(userId){\n const name=prompt('Token name', 'API token');\n if(name===null) return;\n try{\n const j=await post(`/api/auth/users/${userId}/tokens`, {name:name||'API token'});\n const token=j.token?.token||'';\n renderGeneratedToken(token);\n await copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy the API token from the Users panel','warning'));\n await loadAuthUsers();\n }catch(e){ toast(e.message,'danger'); }\n }\n function resetAuthUserForm(){ ['authUserId','authUsername','authPassword'].forEach(id=>{ if($(id)) $(id).value=''; }); if($('authRole')) $('authRole').value='user'; if($('authProfile')) $('authProfile').value='0'; if($('authAccess')) $('authAccess').value='ro'; if($('authActive')) $('authActive').checked=true; $('authUserCancelBtn')?.classList.add('d-none'); }\n function editAuthUser(user){ if(!user) return; if($('authUserId')) $('authUserId').value=user.id||''; if($('authUsername')) $('authUsername').value=user.username||''; if($('authPassword')) $('authPassword').value=''; if($('authRole')) $('authRole').value=user.role||'user'; if($('authActive')) $('authActive').checked=!!user.is_active; const perm=(user.permissions||[])[0]||{profile_id:0,access_level:'ro'}; if($('authProfile')) $('authProfile').value=String(perm.profile_id||0); if($('authAccess')) $('authAccess').value=perm.access_level||'ro'; $('authUserCancelBtn')?.classList.remove('d-none'); }\n async function saveAuthUser(){\n const id=$('authUserId')?.value||'';\n const role=$('authRole')?.value||'user';\n const payload={username:$('authUsername')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\n if(!$('authPassword')?.classList.contains('d-none')) payload.password=$('authPassword')?.value||'';\n // Note: TinyAuth keeps passwords outside pyTorrent, so the hidden field is never submitted.\n try{ await post(id?`/api/auth/users/${id}`:'/api/auth/users',payload,id?'PUT':'POST'); toast('User saved','success'); resetAuthUserForm(); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); }\n }\n"; diff --git a/pytorrent/static/js/bootstrap.js b/pytorrent/static/js/bootstrap.js index d882508..53927a6 100644 --- a/pytorrent/static/js/bootstrap.js +++ b/pytorrent/static/js/bootstrap.js @@ -1 +1 @@ -export const bootstrapSource = " async function loadInitialSnapshotFallback(reason=''){\n if(initialLoaderDone) return;\n try{\n const profilesResp = await fetch('/api/profiles', {cache:'no-store'});\n const profilesJson = await profilesResp.json().catch(()=>({ok:false}));\n const active = profilesJson.active || null;\n if(!active){ showFirstRunSetup(); return; }\n const torrentsResp = await fetch('/api/torrents', {cache:'no-store'});\n const j = await torrentsResp.json().catch(()=>({ok:false,error:'Invalid /api/torrents response'}));\n if(j.ok === false) throw new Error(j.error || 'Torrent API failed');\n const rows = j.torrents || [];\n if(j.error && !rows.length){ renderRtorrentStartingState(j.error, true); hideInitialLoader(); return; }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = j.summary || null;\n torrents.clear();\n rows.forEach(t=>torrents.set(t.hash,t));\n if(j.speed_status) applyLiveSpeedStats(j.speed_status); else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(true);\n hideInitialLoader();\n }catch(e){\n setInitialLoader('Waiting for rTorrent...', (reason ? reason + ': ' : '') + (e.message || 'Unable to load torrent data.'));\n renderRtorrentStartingState(e.message || reason || 'Unable to load torrent data.', true);\n hideInitialLoader();\n }\n }\n async function refreshTorrentSnapshot(reason='') {\n if(!hasActiveProfile) return;\n try {\n const response = await fetch('/api/torrents', {cache:'no-store'});\n const data = await response.json().catch(() => ({ok:false,error:'Invalid /api/torrents response'}));\n if(data.ok === false) throw new Error(data.error || 'Torrent API failed');\n const rows = data.torrents || [];\n if(data.error && !rows.length) {\n renderRtorrentStartingState(data.error, true);\n return;\n }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = data.summary || null;\n torrents.clear();\n selected.clear();\n rows.forEach(t => torrents.set(t.hash, t));\n if(data.speed_status) applyLiveSpeedStats(data.speed_status);\n else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(false);\n } catch (error) {\n if(reason) recordNotification('error', 'Torrent refresh failed', error.message || reason);\n }\n }\n setTimeout(()=>loadInitialSnapshotFallback('Socket fallback'), 4000);\n if(!socket || !socket.io || typeof socket.on !== 'function') setTimeout(()=>loadInitialSnapshotFallback('Socket.IO unavailable'), 200);\n socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{const rows=msg.torrents||[]; if(msg.error && !rows.length){ renderRtorrentStartingState(msg.error, true); return; } clearRtorrentStartingState(); hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();rows.forEach(t=>torrents.set(t.hash,t));if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle();scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{patchRows(msg);scheduleTrackerSummary(false);}); socket.on('job_update',msg=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); if(['done','failed','cancelled'].includes(String(msg?.status||''))) setTimeout(()=>refreshTorrentSnapshot('job_update'), 350); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');if(shouldShowOperationToast(msg)) toastMessage('toast.operationStarted','secondary',{action:msg.action});}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);refreshTorrentSnapshot('operation_finished');if(shouldShowOperationToast(msg)) toastMessage('toast.operationDone','success',{action:msg.action});}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);if(shouldShowOperationToast(msg)) toastMessage('toast.operationFailed','danger',{action:msg.action,error:msg.error});}); socket.on('rtorrent_error',msg=>{ if(msg.error){ recordNotification('error','rTorrent error',msg.error);$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} else if(socket.connected){clearRtorrentStartingState();$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg?.enabled && !msg.cooldown_skipped) recordNotification('queue','Smart Queue decision',smartQueueToastMessage(msg)); if(msg?.cooldown_remaining_seconds!==undefined) updateCooldownBadge('smartCooldownBadge', Number(msg.cooldown_remaining_seconds||0)); if(msg && msg.enabled && !msg.cooldown_skipped && smartQueueToastsEnabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.error) recordNotification('error','Automation error',msg.error); if(msg?.applied?.length) recordNotification('info','Automation applied',`${msg.applied.length} item(s)`); if(msg?.applied?.length && automationToastsEnabled) toastMessage('toast.automationsApplied','secondary',{count:msg.applied.length}); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toastMessage('toast.torrentStatsError','danger',{error:msg.error}); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toastMessage('toast.startupConfigApplied','success',{count:msg.result.updated.length}); if(msg?.error) toastMessage('toast.startupConfigFailed','danger',{error:msg.error}); }); socket.on('download_plan_update',msg=>{ if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed||msg.pause_reason)) recordNotification('planner','Planner action',`paused ${msg.paused||0}, resumed ${msg.resumed||0}${msg.pause_reason?`, ${msg.pause_reason}`:''}`); if(msg?.settings) fillPlanner(msg.settings); if(msg?.preview) renderPlannerPreview(msg.preview); else if(msg?.matched_rule) renderPlannerPreview(msg); if(msg?.history) renderPlannerHistory(msg.history); if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed)) toastMessage('toast.plannerSocketResult','secondary',{paused:msg.paused,resumed:msg.resumed,dryRun:msg.dry_run}); }); socket.on('poller_settings',msg=>fillPoller(msg?.settings||{},msg?.runtime||{}));\n function rtorrentPairText(current, max){\n if(current == null) return '-';\n return max == null ? String(current) : `${current}/${max}`;\n }\n function footerStatusUpdatedText(s={}){\n const value=s.footer_updated_at || s.updated_at;\n if(!value) return '';\n const date=new Date(value);\n return Number.isNaN(date.getTime()) ? '' : ` · last known ${date.toLocaleString()}`;\n }\n function updateRtorrentFooterStats(s={}, cached=false){\n const suffix=cached ? footerStatusUpdatedText(s) : '';\n const sockets=rtorrentPairText(s.open_sockets, s.max_open_sockets);\n if($('statSockets')) $('statSockets').textContent=sockets;\n if($('statusSockets')) $('statusSockets').title=s.open_sockets == null ? `Open sockets unavailable${suffix}` : `Open rTorrent sockets${s.max_open_sockets == null ? '' : ' / max'}: ${sockets}${suffix}`;\n if($('statRtDownloads')) $('statRtDownloads').textContent=rtorrentPairText(s.active_downloads, s.max_downloads_global);\n if($('statusRtDownloads')) $('statusRtDownloads').title=`Active rTorrent downloads / max global downloads${suffix}`;\n if($('statRtUploads')) $('statRtUploads').textContent=rtorrentPairText(s.active_uploads, s.max_uploads_global);\n if($('statusRtUploads')) $('statusRtUploads').title=`Active rTorrent uploads / max global uploads${suffix}`;\n if($('statRtHttp')) $('statRtHttp').textContent=rtorrentPairText(s.open_http, s.max_open_http);\n if($('statusRtHttp')) $('statusRtHttp').title=`Open rTorrent HTTP connections / max HTTP connections${suffix}`;\n if($('statRtFiles')) $('statRtFiles').textContent=rtorrentPairText(s.open_files, s.max_open_files);\n if($('statusRtFiles')) $('statusRtFiles').title=`Open rTorrent files / max open files${suffix}`;\n if($('statRtPort')) $('statRtPort').textContent=(s.listen_port ?? '-') || '-';\n if($('statusRtPort')) $('statusRtPort').title=`rTorrent incoming port${suffix}`;\n if(cached){\n if(s.cpu!==undefined && $('statCpu')) $('statCpu').textContent=s.cpu;\n if(s.ram!==undefined && $('statRam')) $('statRam').textContent=s.ram;\n if(s.version!==undefined && $('statVersion')) $('statVersion').textContent=s.version || '-';\n if(s.down_rate_h!==undefined && $('statDl')) $('statDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('statUl')) $('statUl').textContent=s.up_rate_h || '0 B/s';\n if(s.down_rate_h!==undefined && $('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h || '0 B/s';\n updateBrowserSpeedTitle(s.down_rate_h, s.up_rate_h);\n }\n }\n function saveFooterStatusCache(s={}){\n const payload={\n open_sockets:s.open_sockets, max_open_sockets:s.max_open_sockets,\n active_downloads:s.active_downloads, max_downloads_global:s.max_downloads_global,\n active_uploads:s.active_uploads, max_uploads_global:s.max_uploads_global,\n open_http:s.open_http, max_open_http:s.max_open_http,\n open_files:s.open_files, max_open_files:s.max_open_files,\n listen_port:s.listen_port,\n cpu:s.cpu, ram:s.ram, version:s.version,\n down_rate_h:s.down_rate_h, up_rate_h:s.up_rate_h,\n footer_updated_at:new Date().toISOString()\n };\n try{ localStorage.setItem(FOOTER_STATUS_STORAGE_KEY, JSON.stringify(payload)); }catch(_){}\n }\n function restoreFooterStatusCache(){\n try{\n const cached=JSON.parse(localStorage.getItem(FOOTER_STATUS_STORAGE_KEY)||'null');\n if(cached && typeof cached==='object') updateRtorrentFooterStats(cached, true);\n }catch(_){}\n }\n async function refreshFooterStatusNow(){\n try{\n const res=await fetch('/api/system/status', {cache:'no-store'});\n const j=await res.json();\n const s=j.status||{};\n if(j.ok && s){\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n applyFooterPreferences();\n }\n }catch(_){}\n }\n socket.on('system_stats',s=>{\n const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined;\n $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);\n $('statRamBox')?.classList.toggle('d-none',!usageAvailable);\n $('systemChart')?.classList.toggle('d-none',!usageAvailable);\n if(usageAvailable){\n $('statCpu').textContent=s.cpu??'-';\n $('statRam').textContent=s.ram??'-';\n drawSystemUsage(s.cpu,s.ram);\n }\n $('statVersion').textContent=s.version||'-';\n applyLiveSpeedStats(s);\n lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};\n $('statDlLimit').textContent=s.down_limit_h||'∞';\n $('statUlLimit').textContent=s.up_limit_h||'∞';\n $('statTotalDl').textContent=compactTransferText(s.total_down_h);\n $('statTotalUl').textContent=compactTransferText(s.total_up_h);\n updateSpeedPeaks(s.speed_peaks||{});\n drawTraffic(s.down_rate,s.up_rate);\n if(diskMonitorMode==='default'){\n drawDiskUsage(s.disk);\n }else{\n refreshUserDiskUsage(false);\n }\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n if(s.poller) fillPoller(null,s.poller);\n applyFooterPreferences();\n });\n document.addEventListener('change',e=>{ const sort=e.target.closest('#mobileSortSelect'); if(sort){ setMobileSortValue(sort.value); return; } const sel=e.target.closest('#mobileFilterSelect'); if(!sel) return; setMobileFilterValue(sel.value); });\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n"; +export const bootstrapSource = " async function loadInitialSnapshotFallback(reason=''){\n if(initialLoaderDone) return;\n try{\n const profilesResp = await fetch('/api/profiles', {cache:'no-store'});\n const profilesJson = await profilesResp.json().catch(()=>({ok:false}));\n const active = profilesJson.active || null;\n if(!active){ showFirstRunSetup(); return; }\n const torrentsResp = await fetch('/api/torrents', {cache:'no-store'});\n const j = await torrentsResp.json().catch(()=>({ok:false,error:'Invalid /api/torrents response'}));\n if(j.ok === false) throw new Error(j.error || 'Torrent API failed');\n const rows = j.torrents || [];\n if(j.error && !rows.length){ renderRtorrentStartingState(j.error, true); hideInitialLoader(); return; }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = j.summary || null;\n torrents.clear();\n rows.forEach(t=>torrents.set(t.hash,t));\n if(j.speed_status) applyLiveSpeedStats(j.speed_status); else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(true);\n hideInitialLoader();\n }catch(e){\n setInitialLoader('Waiting for rTorrent...', (reason ? reason + ': ' : '') + (e.message || 'Unable to load torrent data.'));\n renderRtorrentStartingState(e.message || reason || 'Unable to load torrent data.', true);\n hideInitialLoader();\n }\n }\n async function refreshTorrentSnapshot(reason='') {\n if(!hasActiveProfile) return;\n try {\n const response = await fetch('/api/torrents', {cache:'no-store'});\n const data = await response.json().catch(() => ({ok:false,error:'Invalid /api/torrents response'}));\n if(data.ok === false) throw new Error(data.error || 'Torrent API failed');\n const rows = data.torrents || [];\n if(data.error && !rows.length) {\n renderRtorrentStartingState(data.error, true);\n return;\n }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = data.summary || null;\n torrents.clear();\n selected.clear();\n rows.forEach(t => torrents.set(t.hash, t));\n if(data.speed_status) applyLiveSpeedStats(data.speed_status);\n else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(false);\n } catch (error) {\n if(reason) recordNotification('error', 'Torrent refresh failed', error.message || reason);\n }\n }\n setTimeout(()=>loadInitialSnapshotFallback('Socket fallback'), 4000);\n if(!socket || !socket.io || typeof socket.on !== 'function') setTimeout(()=>loadInitialSnapshotFallback('Socket.IO unavailable'), 200);\n socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{const rows=msg.torrents||[]; if(msg.error && !rows.length){ renderRtorrentStartingState(msg.error, true); return; } clearRtorrentStartingState(); hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();rows.forEach(t=>torrents.set(t.hash,t));if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle();scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{patchRows(msg);scheduleTrackerSummary(false);}); socket.on('job_update',msg=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); if(['done','failed','cancelled'].includes(String(msg?.status||''))) setTimeout(()=>refreshTorrentSnapshot('job_update'), 350); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');if(shouldShowOperationToast(msg)) toastMessage('toast.operationStarted','secondary',{action:msg.action});}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);refreshTorrentSnapshot('operation_finished');if(shouldShowOperationToast(msg)) toastMessage('toast.operationDone','success',{action:msg.action});}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);if(shouldShowOperationToast(msg)) toastMessage('toast.operationFailed','danger',{action:msg.action,error:msg.error});}); socket.on('rtorrent_error',msg=>{ if(msg.error){ recordNotification('error','rTorrent error',msg.error);$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.'); scheduleRtorrentStartingState(msg.error);} else if(socket.connected){clearRtorrentStartingState();$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg?.enabled && !msg.cooldown_skipped) recordNotification('queue','Smart Queue decision',smartQueueToastMessage(msg)); if(msg?.cooldown_remaining_seconds!==undefined) updateCooldownBadge('smartCooldownBadge', Number(msg.cooldown_remaining_seconds||0)); if(msg && msg.enabled && !msg.cooldown_skipped && smartQueueToastsEnabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.error) recordNotification('error','Automation error',msg.error); if(msg?.applied?.length) recordNotification('info','Automation applied',`${msg.applied.length} item(s)`); if(msg?.applied?.length && automationToastsEnabled) toastMessage('toast.automationsApplied','secondary',{count:msg.applied.length}); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toastMessage('toast.torrentStatsError','danger',{error:msg.error}); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toastMessage('toast.startupConfigApplied','success',{count:msg.result.updated.length}); if(msg?.error) toastMessage('toast.startupConfigFailed','danger',{error:msg.error}); }); socket.on('download_plan_update',msg=>{ if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed||msg.pause_reason)) recordNotification('planner','Planner action',`paused ${msg.paused||0}, resumed ${msg.resumed||0}${msg.pause_reason?`, ${msg.pause_reason}`:''}`); if(msg?.settings) fillPlanner(msg.settings); if(msg?.preview) renderPlannerPreview(msg.preview); else if(msg?.matched_rule) renderPlannerPreview(msg); if(msg?.history) renderPlannerHistory(msg.history); if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed)) toastMessage('toast.plannerSocketResult','secondary',{paused:msg.paused,resumed:msg.resumed,dryRun:msg.dry_run}); }); socket.on('poller_settings',msg=>fillPoller(msg?.settings||{},msg?.runtime||{}));\n function rtorrentPairText(current, max){\n if(current == null) return '-';\n return max == null ? String(current) : `${current}/${max}`;\n }\n function footerStatusUpdatedText(s={}){\n const value=s.footer_updated_at || s.updated_at;\n if(!value) return '';\n const date=new Date(value);\n return Number.isNaN(date.getTime()) ? '' : ` · last known ${date.toLocaleString()}`;\n }\n function updateRtorrentFooterStats(s={}, cached=false){\n const suffix=cached ? footerStatusUpdatedText(s) : '';\n const sockets=rtorrentPairText(s.open_sockets, s.max_open_sockets);\n if($('statSockets')) $('statSockets').textContent=sockets;\n if($('statusSockets')) $('statusSockets').title=s.open_sockets == null ? `Open sockets unavailable${suffix}` : `Open rTorrent sockets${s.max_open_sockets == null ? '' : ' / max'}: ${sockets}${suffix}`;\n if($('statRtDownloads')) $('statRtDownloads').textContent=rtorrentPairText(s.active_downloads, s.max_downloads_global);\n if($('statusRtDownloads')) $('statusRtDownloads').title=`Active rTorrent downloads / max global downloads${suffix}`;\n if($('statRtUploads')) $('statRtUploads').textContent=rtorrentPairText(s.active_uploads, s.max_uploads_global);\n if($('statusRtUploads')) $('statusRtUploads').title=`Active rTorrent uploads / max global uploads${suffix}`;\n if($('statRtHttp')) $('statRtHttp').textContent=rtorrentPairText(s.open_http, s.max_open_http);\n if($('statusRtHttp')) $('statusRtHttp').title=`Open rTorrent HTTP connections / max HTTP connections${suffix}`;\n if($('statRtFiles')) $('statRtFiles').textContent=rtorrentPairText(s.open_files, s.max_open_files);\n if($('statusRtFiles')) $('statusRtFiles').title=`Open rTorrent files / max open files${suffix}`;\n if($('statRtPort')) $('statRtPort').textContent=(s.listen_port ?? '-') || '-';\n if($('statusRtPort')) $('statusRtPort').title=`rTorrent incoming port${suffix}`;\n if(cached){\n if(s.cpu!==undefined && $('statCpu')) $('statCpu').textContent=s.cpu;\n if(s.ram!==undefined && $('statRam')) $('statRam').textContent=s.ram;\n if(s.version!==undefined && $('statVersion')) $('statVersion').textContent=s.version || '-';\n if(s.down_rate_h!==undefined && $('statDl')) $('statDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('statUl')) $('statUl').textContent=s.up_rate_h || '0 B/s';\n if(s.down_rate_h!==undefined && $('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h || '0 B/s';\n if(s.up_rate_h!==undefined && $('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h || '0 B/s';\n updateBrowserSpeedTitle(s.down_rate_h, s.up_rate_h);\n }\n }\n function saveFooterStatusCache(s={}){\n const payload={\n open_sockets:s.open_sockets, max_open_sockets:s.max_open_sockets,\n active_downloads:s.active_downloads, max_downloads_global:s.max_downloads_global,\n active_uploads:s.active_uploads, max_uploads_global:s.max_uploads_global,\n open_http:s.open_http, max_open_http:s.max_open_http,\n open_files:s.open_files, max_open_files:s.max_open_files,\n listen_port:s.listen_port,\n cpu:s.cpu, ram:s.ram, version:s.version,\n down_rate_h:s.down_rate_h, up_rate_h:s.up_rate_h,\n footer_updated_at:new Date().toISOString()\n };\n try{ localStorage.setItem(FOOTER_STATUS_STORAGE_KEY, JSON.stringify(payload)); }catch(_){}\n }\n function restoreFooterStatusCache(){\n try{\n const cached=JSON.parse(localStorage.getItem(FOOTER_STATUS_STORAGE_KEY)||'null');\n if(cached && typeof cached==='object') updateRtorrentFooterStats(cached, true);\n }catch(_){}\n }\n async function refreshFooterStatusNow(){\n try{\n const res=await fetch('/api/system/status', {cache:'no-store'});\n const j=await res.json();\n const s=j.status||{};\n if(j.ok && s){\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n applyFooterPreferences();\n }\n }catch(_){}\n }\n socket.on('system_stats',s=>{\n const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined;\n $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);\n $('statRamBox')?.classList.toggle('d-none',!usageAvailable);\n $('systemChart')?.classList.toggle('d-none',!usageAvailable);\n if(usageAvailable){\n $('statCpu').textContent=s.cpu??'-';\n $('statRam').textContent=s.ram??'-';\n drawSystemUsage(s.cpu,s.ram);\n }\n $('statVersion').textContent=s.version||'-';\n applyLiveSpeedStats(s);\n lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};\n $('statDlLimit').textContent=s.down_limit_h||'∞';\n $('statUlLimit').textContent=s.up_limit_h||'∞';\n $('statTotalDl').textContent=compactTransferText(s.total_down_h);\n $('statTotalUl').textContent=compactTransferText(s.total_up_h);\n updateSpeedPeaks(s.speed_peaks||{});\n drawTraffic(s.down_rate,s.up_rate);\n if(diskMonitorMode==='default'){\n drawDiskUsage(s.disk);\n }else{\n refreshUserDiskUsage(false);\n }\n updateRtorrentFooterStats(s, false);\n saveFooterStatusCache(s);\n if(s.poller) fillPoller(null,s.poller);\n applyFooterPreferences();\n });\n document.addEventListener('change',e=>{ const sort=e.target.closest('#mobileSortSelect'); if(sort){ setMobileSortValue(sort.value); return; } const sel=e.target.closest('#mobileFilterSelect'); if(!sel) return; setMobileFilterValue(sel.value); });\n updateSortHeaders(); setupColumnResizers(); applyColumnVisibility(); renderColumnManager(); restoreFooterStatusCache(); refreshFooterStatusNow(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setupTorrentDropZone(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); ensureDashboardToolsUI(); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); if(hasActiveProfile) refreshUserDiskUsage(true).catch(()=>{}); scheduleTrackerSummary(true);\n"; diff --git a/pytorrent/static/js/dashboard.js b/pytorrent/static/js/dashboard.js index 464c218..f54fb7f 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 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=>``).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[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n // Note: The hint makes the card action explicit without changing existing filter behavior.\n box.innerHTML=`
Click any block to open the torrent list view filtered by that Smart View.
${SMART_VIEW_DEFS.map(([key,label,note])=>``).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=`
${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=>``).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[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n // Note: The hint makes the card action explicit without changing existing filter behavior.\n box.innerHTML=`
Click any block to open the torrent list view filtered by that Smart View.
${SMART_VIEW_DEFS.map(([key,label,note])=>``).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=`
${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 29d360f..bcd1a98 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':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 mobileVisibleRows(){\n // Note: Mobile bulk selection always uses the active mobile view: current filters, tracker/label scope, search and sort.\n return visibleRows.length ? visibleRows : [...torrents.values()].filter(rowVisible).sort(compareRows);\n }\n\n function mobileSelectionScopeHashes(rows = mobileVisibleRows()){\n return rows.map(t=>t.hash).filter(Boolean);\n }\n\n function setSelectionAnchorsFromCurrentSelection(){\n selectedHash = selected.size ? [...selected][selected.size - 1] : null;\n lastSelectedHash = selectedHash;\n }\n\n function toggleMobileVisibleSelection(){\n const hashes = mobileSelectionScopeHashes();\n const allSelected = hashes.length > 0 && hashes.every(hash=>selected.has(hash));\n // Note: Select all and Unselect all share one scope, so the button label and action stay in sync.\n hashes.forEach(hash=>allSelected ? selected.delete(hash) : selected.add(hash));\n setSelectionAnchorsFromCurrentSelection();\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 ``; }).join('');\n const bulk=selected.size?``:'';\n // Note: Mobile sorting stays as a single cycle button while the step list covers every sortable column.\n bar.innerHTML=`
${bulk}${selected.size} selected
`;\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 mobileVisibleRows(){\n // Note: Mobile bulk selection always uses the active mobile view: current filters, tracker/label scope, search and sort.\n return visibleRows.length ? visibleRows : [...torrents.values()].filter(rowVisible).sort(compareRows);\n }\n\n function mobileSelectionScopeHashes(rows = mobileVisibleRows()){\n return rows.map(t=>t.hash).filter(Boolean);\n }\n\n function setSelectionAnchorsFromCurrentSelection(){\n selectedHash = selected.size ? [...selected][selected.size - 1] : null;\n lastSelectedHash = selectedHash;\n }\n\n function toggleMobileVisibleSelection(){\n const hashes = mobileSelectionScopeHashes();\n const allSelected = hashes.length > 0 && hashes.every(hash=>selected.has(hash));\n // Note: Select all and Unselect all share one scope, so the button label and action stay in sync.\n hashes.forEach(hash=>allSelected ? selected.delete(hash) : selected.add(hash));\n setSelectionAnchorsFromCurrentSelection();\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 ``; }).join('');\n const bulk=selected.size?``:'';\n // Note: Mobile sorting stays as a single cycle button while the step list covers every sortable column.\n bar.innerHTML=`
${bulk}${selected.size} selected
`;\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/modals.js b/pytorrent/static/js/modals.js index e041d95..c66ee70 100644 --- a/pytorrent/static/js/modals.js +++ b/pytorrent/static/js/modals.js @@ -1 +1 @@ -export const modalsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `
${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'
No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`
${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n function columnPrefsPayload(){\n return JSON.stringify({hidden:cleanColumnPrefsHidden(hiddenColumns), shown:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS).filter(key => !hiddenColumns.has(key)), mobile:mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, widths:columnWidths});\n }\n function parseTableColumnsPreference(value){\n if(!value) return {};\n if(typeof value === 'object') return value;\n try{ return JSON.parse(value); }catch(e){ return {}; }\n }\n function applyTableColumnsPreference(value){\n const prefs = parseTableColumnsPreference(value);\n const explicitlyShown = new Set(prefs.shown || []);\n hiddenColumns = new Set([...(prefs.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShown.has(key))]);\n mobileColumns = normalizeMobileColumns(prefs.mobile || {});\n mobileSortFilters = normalizeMobileSortFilters(prefs.mobileSortFilters || {});\n mobileSmartFiltersEnabled = prefs.mobileSmartFiltersEnabled ?? true;\n columnWidths = normalizeColumnWidths(prefs.widths || {});\n saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths});\n }\n function renderColumnCards(defs, values, inputClass, dataAttr, icon){\n return defs.map(([key,label,hiddenByDefault])=>{\n const active = !!values[key];\n return ``;\n }).join('');\n }\n function renderMobileSortFilterCards(){\n return MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n const active = !!mobileSortFilters[id];\n const direction = step.dir > 0 ? 'ascending' : 'descending';\n return ``;\n }).join('');\n }\n function renderColumnManager(){\n const box=$('columnManager');\n if(!box) return;\n const desktopValues = Object.fromEntries(COLUMN_DEFS.map(([key])=>[key, !hiddenColumns.has(key)]));\n const desktop = renderColumnCards(COLUMN_DEFS, desktopValues, 'column-toggle', 'data-col-key', 'fa-table-columns');\n const mobile = renderColumnCards(MOBILE_COLUMN_DEFS, mobileColumns, 'mobile-column-toggle', 'data-mobile-col-key', 'fa-mobile-screen');\n const mobileSort = renderMobileSortFilterCards();\n const smart = ``;\n box.innerHTML=`
${desktop}
Mobile columns
${mobile}
Mobile sort filters
Only enabled sort choices appear in the mobile Sort button. Defaults keep seeds, upload, download and progress descending.
${mobileSort}
Mobile filter groups
${smart}
`;\n }\n $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); document.querySelectorAll('.mobile-column-toggle').forEach(cb=>mobileColumns[cb.dataset.mobileColKey]=cb.checked); document.querySelectorAll('.mobile-sort-filter-toggle').forEach(cb=>mobileSortFilters[cb.dataset.mobileSortFilter]=cb.checked); mobileSmartFiltersEnabled = $('mobileSmartFiltersToggle')?.checked ?? true; saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(e=>toast(e.message,'danger')); toastMessage('toast.columnsSaved','success'); });\n $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS); mobileColumns = normalizeMobileColumns(); mobileSortFilters = normalizeMobileSortFilters(); mobileSmartFiltersEnabled = true; columnWidths = normalizeColumnWidths(); saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(()=>{}); });\n $('recommendedColumnsBtn')?.addEventListener('click',async()=>{\n try{\n // Note: The recommended layout is applied by the backend and includes desktop, mobile and widths.\n const j = await post('/api/preferences/table-columns/recommended', {});\n applyTableColumnsPreference(j.preferences?.table_columns_json);\n renderColumnManager();\n applyColumnVisibility();\n scheduleRender(true);\n toastMessage('toast.recommendedColumnsApplied','success');\n }catch(e){\n toast(e.message,'danger');\n }\n });\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join('
') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)}`,\n r.source==='automation'?` automation`:(r.is_forced?' forced':'user'),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toastMessage('toast.jobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toastMessage('toast.emergencyJobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}
`).join(''):'
No labels.Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`
Groups
${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; +export const modalsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)}`);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used`);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs`);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files`);\n return meta.length ? `
${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`
${esc(d.name)}
`).join('')||'
No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`
${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n function columnPrefsPayload(){\n return JSON.stringify({hidden:cleanColumnPrefsHidden(hiddenColumns), shown:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS).filter(key => !hiddenColumns.has(key)), mobile:mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, widths:columnWidths});\n }\n function parseTableColumnsPreference(value){\n if(!value) return {};\n if(typeof value === 'object') return value;\n try{ return JSON.parse(value); }catch(e){ return {}; }\n }\n function applyTableColumnsPreference(value){\n const prefs = parseTableColumnsPreference(value);\n const explicitlyShown = new Set(prefs.shown || []);\n hiddenColumns = new Set([...(prefs.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShown.has(key))]);\n mobileColumns = normalizeMobileColumns(prefs.mobile || {});\n mobileSortFilters = normalizeMobileSortFilters(prefs.mobileSortFilters || {});\n mobileSmartFiltersEnabled = prefs.mobileSmartFiltersEnabled ?? true;\n columnWidths = normalizeColumnWidths(prefs.widths || {});\n saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths});\n }\n function renderColumnCards(defs, values, inputClass, dataAttr, icon){\n return defs.map(([key,label,hiddenByDefault])=>{\n const active = !!values[key];\n return ``;\n }).join('');\n }\n function renderMobileSortFilterCards(){\n return MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n const active = !!mobileSortFilters[id];\n const direction = step.dir > 0 ? 'ascending' : 'descending';\n return ``;\n }).join('');\n }\n function renderColumnManager(){\n const box=$('columnManager');\n if(!box) return;\n const desktopValues = Object.fromEntries(COLUMN_DEFS.map(([key])=>[key, !hiddenColumns.has(key)]));\n const desktop = renderColumnCards(COLUMN_DEFS, desktopValues, 'column-toggle', 'data-col-key', 'fa-table-columns');\n const mobile = renderColumnCards(MOBILE_COLUMN_DEFS, mobileColumns, 'mobile-column-toggle', 'data-mobile-col-key', 'fa-mobile-screen');\n const mobileSort = renderMobileSortFilterCards();\n const smart = ``;\n box.innerHTML=`
${desktop}
Mobile columns
${mobile}
Mobile sort filters
Only enabled sort choices appear in the mobile Sort button. Defaults keep seeds, upload, download and progress descending.
${mobileSort}
Mobile filter groups
${smart}
`;\n }\n $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); document.querySelectorAll('.mobile-column-toggle').forEach(cb=>mobileColumns[cb.dataset.mobileColKey]=cb.checked); document.querySelectorAll('.mobile-sort-filter-toggle').forEach(cb=>mobileSortFilters[cb.dataset.mobileSortFilter]=cb.checked); mobileSmartFiltersEnabled = $('mobileSmartFiltersToggle')?.checked ?? true; saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(e=>toast(e.message,'danger')); toastMessage('toast.columnsSaved','success'); });\n $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS); mobileColumns = normalizeMobileColumns(); mobileSortFilters = normalizeMobileSortFilters(); mobileSmartFiltersEnabled = true; columnWidths = normalizeColumnWidths(); saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(()=>{}); });\n $('recommendedColumnsBtn')?.addEventListener('click',async()=>{\n try{\n // Note: The recommended layout is applied by the backend and includes desktop, mobile and widths.\n const j = await post('/api/preferences/table-columns/recommended', {});\n applyTableColumnsPreference(j.preferences?.table_columns_json);\n renderColumnManager();\n applyColumnVisibility();\n scheduleRender(true);\n toastMessage('toast.recommendedColumnsApplied','success');\n }catch(e){\n toast(e.message,'danger');\n }\n });\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join('
') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)}`,\n r.source==='automation'?` automation`:(r.is_forced?' forced':'user'),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toastMessage('toast.jobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toastMessage('toast.emergencyJobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>`
${esc(l.name)}
`).join(''):'
No labels.Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>``).join('') || 'No labels selected.'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>``).join('') || 'No saved labels.'; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`
Groups
${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; diff --git a/pytorrent/static/js/operationLogs.js b/pytorrent/static/js/operationLogs.js index b693370..9e4953a 100644 --- a/pytorrent/static/js/operationLogs.js +++ b/pytorrent/static/js/operationLogs.js @@ -1 +1 @@ -export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_started', 'job_done', 'job_failed'];\n function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')}`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `
${esc(label)}${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `
${esc(x.action)}${esc(x.n)}
`).join('');\n return `
${card('Total logs', stats.total || 0)}${types}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => ``).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n compactCell(r.details_h || '', 220),\n ]), 'operation-log-table');\n if(!rows.length) box.innerHTML = '
No logs.No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Page ${operationLogsPage + 1} / ${pages} · ${total} logs`;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `
${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n"; +export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_started', 'job_done', 'job_failed'];\n function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')}`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `
${esc(label)}${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `
${esc(x.action)}${esc(x.n)}
`).join('');\n return `
${card('Total logs', stats.total || 0)}${types}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => ``).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n compactCell(r.details_h || '', 220),\n ]), 'operation-log-table');\n if(!rows.length) box.innerHTML = '
No logs.No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Page ${operationLogsPage + 1} / ${pages} · ${total} logs`;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `
${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n"; diff --git a/pytorrent/static/js/planner.js b/pytorrent/static/js/planner.js index 896337c..81b4ab0 100644 --- a/pytorrent/static/js/planner.js +++ b/pytorrent/static/js/planner.js @@ -1 +1 @@ -export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n \n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settings
  • Loading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Adaptive WebSocket poller normal
Controls live refresh cadence per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','Clamp unsafe poller settings to known-safe intervals.')}\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n $('pollerReloadBtn')?.addEventListener('click',loadPollerSettings);\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const items=[\n `Status: ${enabled ? 'enabled' : 'off'}${$('plannerDryRun')?.checked ? ' / dry-run' : ''}`,\n `Profile: ${$('plannerProfileName')?.value || state.profile_name || '-'}`,\n `Hourly: ${plannerOnOff($('plannerHourlyEnabled')?.checked)}`,\n `Night: ${plannerOnOff($('plannerNightOnly')?.checked)} ${$('plannerNightStart')?.value || state.night_start || '--:--'}-${$('plannerNightEnd')?.value || state.night_end || '--:--'}`,\n `Quiet: ${plannerOnOff($('plannerQuietEnabled')?.checked)} ${$('plannerQuietStart')?.value || state.quiet_start || '--:--'}-${$('plannerQuietEnd')?.value || state.quiet_end || '--:--'}`,\n `Protection: CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`,\n ];\n box.innerHTML=`
Current settings
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' · dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} · next change ${esc(plannerDateText(preview.next_change_at))} · DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' · pauses downloads':''}${preview.manual_override_until?' · override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; +export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n \n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settings
  • Loading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Adaptive WebSocket poller normal
Controls live refresh cadence per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','Clamp unsafe poller settings to known-safe intervals.')}\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n $('pollerReloadBtn')?.addEventListener('click',loadPollerSettings);\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const items=[\n `Status: ${enabled ? 'enabled' : 'off'}${$('plannerDryRun')?.checked ? ' / dry-run' : ''}`,\n `Profile: ${$('plannerProfileName')?.value || state.profile_name || '-'}`,\n `Hourly: ${plannerOnOff($('plannerHourlyEnabled')?.checked)}`,\n `Night: ${plannerOnOff($('plannerNightOnly')?.checked)} ${$('plannerNightStart')?.value || state.night_start || '--:--'}-${$('plannerNightEnd')?.value || state.night_end || '--:--'}`,\n `Quiet: ${plannerOnOff($('plannerQuietEnabled')?.checked)} ${$('plannerQuietStart')?.value || state.quiet_start || '--:--'}-${$('plannerQuietEnd')?.value || state.quiet_end || '--:--'}`,\n `Protection: CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`,\n ];\n box.innerHTML=`
Current settings
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' · dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} · next change ${esc(plannerDateText(preview.next_change_at))} · DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' · pauses downloads':''}${preview.manual_override_until?' · override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; diff --git a/pytorrent/static/js/poller.js b/pytorrent/static/js/poller.js index 5fa70f1..fc3a7b3 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 · 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();}); $('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','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('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=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){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) 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(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('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 mobileDetails=e.target.closest('.mobile-details-btn'); if(mobileDetails){ const card0=mobileDetails.closest('.mobile-card'); if(card0?.dataset.hash) openMobileDetails(card0.dataset.hash); 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); $('compactTorrentListEnabled')?.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();}); $('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','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('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=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){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) 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(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('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 mobileDetails=e.target.closest('.mobile-details-btn'); if(mobileDetails){ const card0=mobileDetails.closest('.mobile-card'); if(card0?.dataset.hash) openMobileDetails(card0.dataset.hash); 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); $('compactTorrentListEnabled')?.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/profiles.js b/pytorrent/static/js/profiles.js index 71e0680..13d93b0 100644 --- a/pytorrent/static/js/profiles.js +++ b/pytorrent/static/js/profiles.js @@ -1 +1 @@ -export const profilesSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=d.status || 'unknown'; const cls=st==='online'?'success':st==='slow'?'warning':st==='error'?'danger':'secondary'; const isActive=String(p.id)===active; return `
${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · API ${esc(p.api_limit_per_minute||'-')}/min · poll ${esc(p.polling_min_interval_seconds||'-')}s${d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''}
`; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=d.status || (d.ok?'online':'error'); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',status],['rTorrent',d.version||'-'],['Library',d.library_version||'-'],['Response',d.response_time_ms!=null?`${d.response_time_ms} ms`:'-'],['Default path',paths.default_directory||'-'],['CWD',paths.cwd||'-'],['Write',Object.values(wp)[0]||'-'],['Free disk',firstDisk.free_h||firstDisk.error||'-']]; box.innerHTML=`
${cards.map(([k,v])=>`
${esc(k)}${esc(v)}
`).join('')}
${d.error?`
${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n hasTorrentSnapshot=false;\n torrentSummary=null;\n trackerSummary={hashes:{}, trackers:[], scanned:0, errors:[]};\n trackerSummaryStatus='idle';\n trackerSummarySignature='';\n torrents.clear();\n selected.clear();\n selectedHash=null;\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');}); $('profilePickerUseBtn')?.addEventListener('click',async()=>{const select=$('profileSelect');const id=select?.value;if(!id)return;const opt=select.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n"; +export const profilesSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=d.status || 'unknown'; const cls=st==='online'?'success':st==='slow'?'warning':st==='error'?'danger':'secondary'; const isActive=String(p.id)===active; return `
${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · API ${esc(p.api_limit_per_minute||'-')}/min · poll ${esc(p.polling_min_interval_seconds||'-')}s${d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''}
`; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=d.status || (d.ok?'online':'error'); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',status],['rTorrent',d.version||'-'],['Library',d.library_version||'-'],['Response',d.response_time_ms!=null?`${d.response_time_ms} ms`:'-'],['Default path',paths.default_directory||'-'],['CWD',paths.cwd||'-'],['Write',Object.values(wp)[0]||'-'],['Free disk',firstDisk.free_h||firstDisk.error||'-']]; box.innerHTML=`
${cards.map(([k,v])=>`
${esc(k)}${esc(v)}
`).join('')}
${d.error?`
${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n hasTorrentSnapshot=false;\n torrentSummary=null;\n trackerSummary={hashes:{}, trackers:[], scanned:0, errors:[]};\n trackerSummaryStatus='idle';\n trackerSummarySignature='';\n torrents.clear();\n selected.clear();\n selectedHash=null;\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');}); $('profilePickerUseBtn')?.addEventListener('click',async()=>{const select=$('profileSelect');const id=select?.value;if(!id)return;const opt=select.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n"; diff --git a/pytorrent/static/js/smartQueue.js b/pytorrent/static/js/smartQueue.js index 1c98448..63bd726 100644 --- a/pytorrent/static/js/smartQueue.js +++ b/pytorrent/static/js/smartQueue.js @@ -1 +1 @@ -export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:smartHistoryDetails(latest||{}).decision||latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return '
No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' · ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(d.decision||h.event||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : '
No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function normalizeRtConfigValue(value, type='text'){\n const raw=String(value ?? '').trim();\n if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';\n if(type==='number'){\n if(raw==='') return '0';\n const normalized=Number(raw.replace(',', '.'));\n return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;\n }\n return raw;\n }\n function rtConfigInputValue(input){\n const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';\n const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;\n return normalizeRtConfigValue(value, type);\n }\n function rtConfigOriginalValue(input){\n const key=input.dataset.key;\n return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');\n }\n function collectRtConfigChanges(){\n const values={};\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled) return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur!==orig) values[input.dataset.key]=cur;\n });\n return values;\n }\n function collectRtConfigClearKeys(){\n const keys=[];\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled || input.dataset.saved!=='true') return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur===orig) keys.push(input.dataset.key);\n });\n return keys;\n }\n function updateRtConfigDirty(){\n const changed=collectRtConfigChanges();\n const clearKeys=collectRtConfigClearKeys();\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n const row=input.closest('.rt-config-row');\n if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));\n });\n const configChanges=Object.keys(changed).length;\n const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;\n const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);\n if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';\n if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;\n if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;\n }\n async function loadRtConfig(){\n const box=$('rtConfigManager');\n if(!box)return;\n box.innerHTML=' Loading config...';\n try{\n const j=await (await fetch('/api/rtorrent-config')).json();\n if(!j.ok) throw new Error(j.error||'Config load failed');\n const fields=j.config?.fields||[];\n rtConfigOriginal=new Map();\n rtConfigFieldTypes=new Map();\n rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;\n let lastGroup='';\n const html=fields.map(f=>{\n const group=f.group||'Other';\n const head=group!==lastGroup?`
${esc(group)}
`:'';\n lastGroup=group;\n const disabled=(!f.ok||f.readonly)?'disabled':'';\n const type=['bool','number'].includes(f.type)?f.type:'text';\n const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);\n const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);\n rtConfigOriginal.set(f.key, originalValue);\n rtConfigFieldTypes.set(f.key, type);\n const note=f.ok?(f.readonly?' · read only':(f.saved?' · saved override · reference kept':'')):' · unavailable';\n const valueNote=f.saved?`Reference: ${esc(originalValue)} → saved: ${esc(displayValue)}`:'';\n const originalAttr=esc(originalValue);\n const input=type==='bool'\n ? `${displayValue==='1'?'On':'Off'}`\n : ``;\n return `${head}`;\n }).join('');\n box.innerHTML=`
${html}
`;\n if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;\n updateRtConfigDirty();\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function saveRtConfig(){\n const values=collectRtConfigChanges();\n const clear_keys=collectRtConfigClearKeys();\n clear_keys.forEach(key=>{\n const input=document.querySelector(`.rt-config-input[data-key=\"${CSS.escape(key)}\"]`);\n if(input) values[key]=rtConfigOriginalValue(input);\n });\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});\n toastMessage('toast.rtorrentConfigSaved','success',{updated:j.result?.updated?.length});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function resetRtConfig(){\n // Note: Reset clears only saved UI overrides, then reloads the live state from rTorrent.\n if(!confirm('Clear all saved rTorrent UI overrides and reload current rTorrent values?')) return;\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config/reset',{});\n toastMessage('toast.rtorrentConfigReset','success',{removed:j.config?.reset_removed});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }\n\n function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){ bootstrapTheme = theme || \"default\"; const link=$(\"bootstrapThemeStylesheet\"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme; }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n function torrentRowHeight(){ return compactTorrentListEnabled ? COMPACT_ROW_HEIGHT : ROW_HEIGHT; }\n function applyCompactTorrentList(value){\n // Note: The compact switch changes density only; filtering, sorting and existing row actions stay unchanged.\n compactTorrentListEnabled = !!value;\n document.body.classList.toggle(\"compact-torrent-list\", compactTorrentListEnabled);\n if($(\"compactTorrentListEnabled\")) $(\"compactTorrentListEnabled\").checked = compactTorrentListEnabled;\n scheduleRender(true);\n }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); applyCompactTorrentList($(\"compactTorrentListEnabled\")?.checked); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale,compact_torrent_list_enabled:compactTorrentListEnabled}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n applyCompactTorrentList(compactTorrentListEnabled);\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function refreshPeersOnceForReverseDns(){\n // Note: Reverse DNS can resolve after the first peers fetch, so trigger one silent follow-up even when auto-refresh is disabled.\n if(activeTab()==='peers' && selectedHash){\n loadDetails('peers');\n setTimeout(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers',{silent:true}); }, 1200);\n }\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n\n function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' → ')||'no actions';\n return `${cs} → ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' · ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `
${esc(r.name)} ${enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`
${cards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Logs and historyPending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `
${esc(label)}${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveReverseDnsPreference(){\n // Note: Reverse DNS remains opt-in and refreshes only the peers pane, leaving other torrent data untouched.\n reverseDnsEnabled=!!$('reverseDnsEnabled')?.checked;\n try{ await post('/api/preferences',{reverse_dns_enabled:reverseDnsEnabled}); refreshPeersOnceForReverseDns(); toast('Reverse DNS preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function updateFooterClock(){\n const el=$('statClock');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});\n }\n function updateSocketStatus(s={}){\n const el=$('statSockets');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);\n const box=$('statusSockets');\n if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;\n }\n\n function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }\n function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }\n function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }\n function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)}`; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(' · ') || 'Port check disabled';\n if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id=\"portCheckBadge\" ');\n if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' · ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';\n if($('statusPortCheck')){\n $('statusPortCheck').classList.toggle('d-none', !data.enabled);\n $('statusPortCheck').title=title;\n }\n if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id=\"statusPortCheckBadge\" ',true);\n }\n async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\n function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); 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.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\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 panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI 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 ['poller','Poller', diagnosticsSection('Adaptive poller', [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 ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', `
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`]\n ];\n const tabs=`
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? '-')}${note?`${esc(note)}`:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`
${panes.map(p=>`
${p[2].join('')}
`).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=``;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return ``;\n }\n function plannerToggleRow(id,title,description){\n return `
${title}${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `
\n ${title}\n ${sub}\n
Unlimited
\n
\n \n \n \n \n \n \n
\n
\n \n \n \n \n
\n Slider uses Mbit/s. Numeric fields store B/s for rTorrent.\n
`;\n }\n"; +export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:smartHistoryDetails(latest||{}).decision||latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return '
No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' · ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(d.decision||h.event||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : '
No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function normalizeRtConfigValue(value, type='text'){\n const raw=String(value ?? '').trim();\n if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';\n if(type==='number'){\n if(raw==='') return '0';\n const normalized=Number(raw.replace(',', '.'));\n return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;\n }\n return raw;\n }\n function rtConfigInputValue(input){\n const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';\n const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;\n return normalizeRtConfigValue(value, type);\n }\n function rtConfigOriginalValue(input){\n const key=input.dataset.key;\n return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');\n }\n function collectRtConfigChanges(){\n const values={};\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled) return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur!==orig) values[input.dataset.key]=cur;\n });\n return values;\n }\n function collectRtConfigClearKeys(){\n const keys=[];\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled || input.dataset.saved!=='true') return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur===orig) keys.push(input.dataset.key);\n });\n return keys;\n }\n function updateRtConfigDirty(){\n const changed=collectRtConfigChanges();\n const clearKeys=collectRtConfigClearKeys();\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n const row=input.closest('.rt-config-row');\n if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));\n });\n const configChanges=Object.keys(changed).length;\n const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;\n const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);\n if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';\n if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;\n if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;\n }\n async function loadRtConfig(){\n const box=$('rtConfigManager');\n if(!box)return;\n box.innerHTML=' Loading config...';\n try{\n const j=await (await fetch('/api/rtorrent-config')).json();\n if(!j.ok) throw new Error(j.error||'Config load failed');\n const fields=j.config?.fields||[];\n rtConfigOriginal=new Map();\n rtConfigFieldTypes=new Map();\n rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;\n let lastGroup='';\n const html=fields.map(f=>{\n const group=f.group||'Other';\n const head=group!==lastGroup?`
${esc(group)}
`:'';\n lastGroup=group;\n const disabled=(!f.ok||f.readonly)?'disabled':'';\n const type=['bool','number'].includes(f.type)?f.type:'text';\n const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);\n const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);\n rtConfigOriginal.set(f.key, originalValue);\n rtConfigFieldTypes.set(f.key, type);\n const note=f.ok?(f.readonly?' · read only':(f.saved?' · saved override · reference kept':'')):' · unavailable';\n const valueNote=f.saved?`Reference: ${esc(originalValue)} → saved: ${esc(displayValue)}`:'';\n const originalAttr=esc(originalValue);\n const input=type==='bool'\n ? `${displayValue==='1'?'On':'Off'}`\n : ``;\n return `${head}`;\n }).join('');\n box.innerHTML=`
${html}
`;\n if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;\n updateRtConfigDirty();\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function saveRtConfig(){\n const values=collectRtConfigChanges();\n const clear_keys=collectRtConfigClearKeys();\n clear_keys.forEach(key=>{\n const input=document.querySelector(`.rt-config-input[data-key=\"${CSS.escape(key)}\"]`);\n if(input) values[key]=rtConfigOriginalValue(input);\n });\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});\n toastMessage('toast.rtorrentConfigSaved','success',{updated:j.result?.updated?.length});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function resetRtConfig(){\n // Note: Reset clears only saved UI overrides, then reloads the live state from rTorrent.\n if(!confirm('Clear all saved rTorrent UI overrides and reload current rTorrent values?')) return;\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config/reset',{});\n toastMessage('toast.rtorrentConfigReset','success',{removed:j.config?.reset_removed});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }\n\n function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){ bootstrapTheme = theme || \"default\"; const link=$(\"bootstrapThemeStylesheet\"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme; }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n function torrentRowHeight(){ return compactTorrentListEnabled ? COMPACT_ROW_HEIGHT : ROW_HEIGHT; }\n function applyCompactTorrentList(value){\n // Note: The compact switch changes density only; filtering, sorting and existing row actions stay unchanged.\n compactTorrentListEnabled = !!value;\n document.body.classList.toggle(\"compact-torrent-list\", compactTorrentListEnabled);\n if($(\"compactTorrentListEnabled\")) $(\"compactTorrentListEnabled\").checked = compactTorrentListEnabled;\n scheduleRender(true);\n }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); applyCompactTorrentList($(\"compactTorrentListEnabled\")?.checked); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale,compact_torrent_list_enabled:compactTorrentListEnabled}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n applyCompactTorrentList(compactTorrentListEnabled);\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function refreshPeersOnceForReverseDns(){\n // Note: Reverse DNS can resolve after the first peers fetch, so trigger one silent follow-up even when auto-refresh is disabled.\n if(activeTab()==='peers' && selectedHash){\n loadDetails('peers');\n setTimeout(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers',{silent:true}); }, 1200);\n }\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n\n function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' → ')||'no actions';\n return `${cs} → ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))}`).join(''):'No conditions added yet.';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))}`).join(''):'No actions added yet.';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)}`;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' · ')||'action')}`;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `
${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `
${summary||'No actions'}
${details}
`;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `
${esc(r.name)} ${enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`
${cards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Logs and historyPending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `
${esc(label)}${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveReverseDnsPreference(){\n // Note: Reverse DNS remains opt-in and refreshes only the peers pane, leaving other torrent data untouched.\n reverseDnsEnabled=!!$('reverseDnsEnabled')?.checked;\n try{ await post('/api/preferences',{reverse_dns_enabled:reverseDnsEnabled}); refreshPeersOnceForReverseDns(); toast('Reverse DNS preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function updateFooterClock(){\n const el=$('statClock');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});\n }\n function updateSocketStatus(s={}){\n const el=$('statSockets');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);\n const box=$('statusSockets');\n if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;\n }\n\n function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }\n function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }\n function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }\n function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)}`; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(' · ') || 'Port check disabled';\n if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id=\"portCheckBadge\" ');\n if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' · ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';\n if($('statusPortCheck')){\n $('statusPortCheck').classList.toggle('d-none', !data.enabled);\n $('statusPortCheck').title=title;\n }\n if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id=\"statusPortCheckBadge\" ',true);\n }\n async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n reverseDnsEnabled=!!Number(prefs.reverse_dns_enabled ?? (reverseDnsEnabled?1:0));\n if($('reverseDnsEnabled')) $('reverseDnsEnabled').checked=reverseDnsEnabled;\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n compactTorrentListEnabled=Number(prefs.compact_torrent_list_enabled ?? (compactTorrentListEnabled?1:0))!==0;\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); applyCompactTorrentList(compactTorrentListEnabled); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\n function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); 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.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\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 panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI 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 ['poller','Poller', diagnosticsSection('Adaptive poller', [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 ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', `
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? '-')}${note?`${esc(note)}`:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`
    ${panes.map(p=>`
  • `).join('')}
${panes.map(p=>`
${p[2].join('')}
`).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=``;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return ``;\n }\n function plannerToggleRow(id,title,description){\n return `
${title}${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `
\n ${title}\n ${sub}\n
Unlimited
\n
\n \n \n \n \n \n \n
\n
\n \n \n \n \n
\n Slider uses Mbit/s. Numeric fields store B/s for rTorrent.\n
`;\n }\n"; diff --git a/pytorrent/static/js/torrentAdd.js b/pytorrent/static/js/torrentAdd.js index c3b49de..4547179 100644 --- a/pytorrent/static/js/torrentAdd.js +++ b/pytorrent/static/js/torrentAdd.js @@ -1 +1 @@ -export const torrentAddSource = " // Note: Add Torrent modal preview, submission and drag/drop upload handling are grouped here.\n const addPreviewState = {items: []};\n function renderTorrentPreview(items=[]){\n addPreviewState.items = items;\n const box=$('torrentPreview');\n if(!box) return;\n if(!items.length){ box.innerHTML=''; return; }\n const cards=items.map(item=>{\n const files=(item.files||[]).map((f,index)=>`${esc(f.path)}${esc(fmtBytes(f.size||0))}`).join('');\n const limitWarn=item.xmlrpc_too_large?`
Too large for current rTorrent XML-RPC upload limit: request ${esc(item.xmlrpc_request_h||'')} exceeds the configured limit. Change network.xmlrpc.size_limit in rTorrent config, e.g. 16M.
`:'';\n return `
${esc(item.name||item.filename)}${item.duplicate?'duplicate':''}${esc(fmtBytes(item.size||0))} · ${esc(item.file_count||0)} files
${esc(item.info_hash||'')}
${limitWarn}
${files}
`;\n }).join('');\n box.innerHTML=`
Preview before adding
${cards}`;\n }\n async function previewTorrentFiles(){\n const input=$('torrentFiles');\n const files=[...(input?.files||[])];\n $('torrentFilesInfo').textContent=files.length?`Selected files: ${files.length}`:'You can select multiple files at once.';\n if(!files.length) return renderTorrentPreview([]);\n const fd=new FormData();\n files.forEach(f=>fd.append('files',f));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n renderTorrentPreview(j.previews||[]);\n }catch(e){ if($('torrentPreview')) $('torrentPreview').innerHTML=`
${esc(e.message)}
`; }\n }\n function collectPreviewPriorities(){\n const out={};\n addPreviewState.items.forEach(item=>{\n const key=item.info_hash||item.filename;\n out[key]=[...(item.files||[])].map((f,index)=>({index,priority:document.querySelector(`.preview-file-priority[data-torrent=\"${CSS.escape(key)}\"][data-index=\"${index}\"]`)?.checked ? 1 : 0}));\n });\n return out;\n }\n function torrentFilesFromDrop(event){\n return [...(event.dataTransfer?.files||[])].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n }\n function dragHasFiles(event){\n const dt=event.dataTransfer;\n if(!dt) return false;\n if([...(dt.types||[])].includes('Files')) return true;\n return [...(dt.items||[])].some(item=>item.kind==='file');\n }\n async function droppedTorrentSummary(files){\n const fd=new FormData();\n files.forEach(file=>fd.append('files',file));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n const names=(j.previews||[]).map(item=>`${item.duplicate?'[duplicate] ':''}${item.name||item.filename}`).filter(Boolean);\n return names.length ? names : files.map(file=>file.name);\n }catch(e){\n return files.map(file=>file.name);\n }\n }\n async function addDroppedTorrentFiles(files){\n const torrentFiles=[...files].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n if(!torrentFiles.length){ toastMessage('toast.dropOnlyTorrents','warning'); return; }\n const names=await droppedTorrentSummary(torrentFiles);\n const preview=names.slice(0,8).join('\\n');\n const suffix=names.length>8?`\\n...and ${names.length-8} more`:'';\n if(!confirm(`Add ${torrentFiles.length} torrent file(s)?\\n\\n${preview}${suffix}`)) return;\n setBusy(true,'Adding dropped torrent files...');\n try{\n const fd=new FormData();\n fd.append('uris','');\n fd.append('directory',await getDefaultDownloadPath());\n fd.append('label','');\n fd.append('start','1');\n torrentFiles.forEach(file=>fd.append('files',file));\n const res=await fetch('/api/torrents/add',{method:'POST',body:fd});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n const queued=(j.job_ids||[]).length;\n if(queued && skipped) toastMessage('toast.droppedAddedSkipped','warning',{queued, skipped});\n else if(queued) toastMessage('toast.droppedAdded','success',{queued});\n else if(skipped) toastMessage('toast.droppedSkipped','warning',{skipped});\n else toastMessage('toast.droppedNone','warning');\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n document.body.classList.remove('dragging-torrent-files');\n }\n }\n function setupTorrentDropZone(){\n const zones=[$('tableWrap'),$('torrentBody'),$('mobileList'),document.querySelector('.content'),document.body].filter(Boolean);\n let dragDepth=0;\n const markActive=()=>document.body.classList.add('dragging-torrent-files');\n const clearActive=()=>document.body.classList.remove('dragging-torrent-files');\n const onDragEnter=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n dragDepth+=1;\n markActive();\n };\n const onDragOver=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n if(event.dataTransfer) event.dataTransfer.dropEffect='copy';\n markActive();\n };\n const onDragLeave=event=>{\n if(!dragHasFiles(event)) return;\n dragDepth=Math.max(0,dragDepth-1);\n if(!dragDepth) clearActive();\n };\n const onDrop=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n event.stopPropagation();\n dragDepth=0;\n clearActive();\n addDroppedTorrentFiles(event.dataTransfer?.files||[]);\n };\n zones.forEach(zone=>{\n if(zone.dataset?.torrentDropZoneBound==='1') return;\n if(zone.dataset) zone.dataset.torrentDropZoneBound='1';\n zone.addEventListener('dragenter',onDragEnter);\n zone.addEventListener('dragover',onDragOver);\n zone.addEventListener('dragleave',onDragLeave);\n zone.addEventListener('drop',onDrop);\n });\n }\n function hasTooLargeTorrentPreview(){\n // Note: Client-side upload blocking mirrors the server validation and gives feedback before the add request.\n return addPreviewState.items.some(item=>item.xmlrpc_too_large);\n }\n function addTorrentPayload(){\n const fd=new FormData();\n fd.append('uris',$('magnetInput')?.value||'');\n fd.append('directory',$('addPath')?.value||'');\n fd.append('label',$('addLabel')?.value||'');\n fd.append('start',$('addStart')?.checked?'1':'0');\n fd.append('file_priorities',JSON.stringify(collectPreviewPriorities()));\n [...($('torrentFiles')?.files||[])].forEach(f=>fd.append('files',f));\n return fd;\n }\n function resetAddTorrentForm(){\n if($('magnetInput')) $('magnetInput').value='';\n if($('torrentFiles')) $('torrentFiles').value='';\n renderTorrentPreview([]);\n }\n async function addTorrentFromModal(){\n const btn=$('addBtn');\n buttonBusy(btn,true);\n setBusy(true);\n try{\n if(hasTooLargeTorrentPreview()) throw new Error(appMessage('toast.addTooLarge'));\n const res=await fetch('/api/torrents/add',{method:'POST',body:addTorrentPayload()});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n if(skipped) toastMessage('toast.addQueuedSkipped','warning',{count:skipped});\n else toastMessage('toast.addQueued','success');\n resetAddTorrentForm();\n bootstrap.Modal.getInstance($('addModal'))?.hide();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n buttonBusy(btn,false);\n setBusy(false);\n }\n }\n $('addBtn')?.addEventListener('click',addTorrentFromModal); $('torrentFiles')?.addEventListener('change',previewTorrentFiles); $('torrentPreview')?.addEventListener('click',e=>{const card=e.target.closest('.torrent-preview-card'); if(!card) return; if(e.target.closest('.preview-select-all')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=true); if(e.target.closest('.preview-select-none')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=false);});\n"; +export const torrentAddSource = " // Note: Add Torrent modal preview, submission and drag/drop upload handling are grouped here.\n const addPreviewState = {items: []};\n function renderTorrentPreview(items=[]){\n addPreviewState.items = items;\n const box=$('torrentPreview');\n if(!box) return;\n if(!items.length){ box.innerHTML=''; return; }\n const cards=items.map(item=>{\n const files=(item.files||[]).map((f,index)=>`${esc(f.path)}${esc(fmtBytes(f.size||0))}`).join('');\n const limitWarn=item.xmlrpc_too_large?`
Too large for current rTorrent XML-RPC upload limit: request ${esc(item.xmlrpc_request_h||'')} exceeds the configured limit. Change network.xmlrpc.size_limit in rTorrent config, e.g. 16M.
`:'';\n return `
${esc(item.name||item.filename)}${item.duplicate?'duplicate':''}${esc(fmtBytes(item.size||0))} · ${esc(item.file_count||0)} files
${esc(item.info_hash||'')}
${limitWarn}
${files}
`;\n }).join('');\n box.innerHTML=`
Preview before adding
${cards}`;\n }\n async function previewTorrentFiles(){\n const input=$('torrentFiles');\n const files=[...(input?.files||[])];\n $('torrentFilesInfo').textContent=files.length?`Selected files: ${files.length}`:'You can select multiple files at once.';\n if(!files.length) return renderTorrentPreview([]);\n const fd=new FormData();\n files.forEach(f=>fd.append('files',f));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n renderTorrentPreview(j.previews||[]);\n }catch(e){ if($('torrentPreview')) $('torrentPreview').innerHTML=`
${esc(e.message)}
`; }\n }\n function collectPreviewPriorities(){\n const out={};\n addPreviewState.items.forEach(item=>{\n const key=item.info_hash||item.filename;\n out[key]=[...(item.files||[])].map((f,index)=>({index,priority:document.querySelector(`.preview-file-priority[data-torrent=\"${CSS.escape(key)}\"][data-index=\"${index}\"]`)?.checked ? 1 : 0}));\n });\n return out;\n }\n function torrentFilesFromDrop(event){\n return [...(event.dataTransfer?.files||[])].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n }\n function dragHasFiles(event){\n const dt=event.dataTransfer;\n if(!dt) return false;\n if([...(dt.types||[])].includes('Files')) return true;\n return [...(dt.items||[])].some(item=>item.kind==='file');\n }\n async function droppedTorrentSummary(files){\n const fd=new FormData();\n files.forEach(file=>fd.append('files',file));\n try{\n const j=await (await fetch('/api/torrents/preview',{method:'POST',body:fd})).json();\n if(!j.ok) throw new Error(j.error||'Preview failed');\n const names=(j.previews||[]).map(item=>`${item.duplicate?'[duplicate] ':''}${item.name||item.filename}`).filter(Boolean);\n return names.length ? names : files.map(file=>file.name);\n }catch(e){\n return files.map(file=>file.name);\n }\n }\n async function addDroppedTorrentFiles(files){\n const torrentFiles=[...files].filter(file=>/\\.torrent$/i.test(file.name||'') || file.type==='application/x-bittorrent');\n if(!torrentFiles.length){ toastMessage('toast.dropOnlyTorrents','warning'); return; }\n const names=await droppedTorrentSummary(torrentFiles);\n const preview=names.slice(0,8).join('\\n');\n const suffix=names.length>8?`\\n...and ${names.length-8} more`:'';\n if(!confirm(`Add ${torrentFiles.length} torrent file(s)?\\n\\n${preview}${suffix}`)) return;\n setBusy(true,'Adding dropped torrent files...');\n try{\n const fd=new FormData();\n fd.append('uris','');\n fd.append('directory',await getDefaultDownloadPath());\n fd.append('label','');\n fd.append('start','1');\n torrentFiles.forEach(file=>fd.append('files',file));\n const res=await fetch('/api/torrents/add',{method:'POST',body:fd});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n const queued=(j.job_ids||[]).length;\n if(queued && skipped) toastMessage('toast.droppedAddedSkipped','warning',{queued, skipped});\n else if(queued) toastMessage('toast.droppedAdded','success',{queued});\n else if(skipped) toastMessage('toast.droppedSkipped','warning',{skipped});\n else toastMessage('toast.droppedNone','warning');\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n document.body.classList.remove('dragging-torrent-files');\n }\n }\n function setupTorrentDropZone(){\n const zones=[$('tableWrap'),$('torrentBody'),$('mobileList'),document.querySelector('.content'),document.body].filter(Boolean);\n let dragDepth=0;\n const markActive=()=>document.body.classList.add('dragging-torrent-files');\n const clearActive=()=>document.body.classList.remove('dragging-torrent-files');\n const onDragEnter=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n dragDepth+=1;\n markActive();\n };\n const onDragOver=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n if(event.dataTransfer) event.dataTransfer.dropEffect='copy';\n markActive();\n };\n const onDragLeave=event=>{\n if(!dragHasFiles(event)) return;\n dragDepth=Math.max(0,dragDepth-1);\n if(!dragDepth) clearActive();\n };\n const onDrop=event=>{\n if(!dragHasFiles(event)) return;\n event.preventDefault();\n event.stopPropagation();\n dragDepth=0;\n clearActive();\n addDroppedTorrentFiles(event.dataTransfer?.files||[]);\n };\n zones.forEach(zone=>{\n if(zone.dataset?.torrentDropZoneBound==='1') return;\n if(zone.dataset) zone.dataset.torrentDropZoneBound='1';\n zone.addEventListener('dragenter',onDragEnter);\n zone.addEventListener('dragover',onDragOver);\n zone.addEventListener('dragleave',onDragLeave);\n zone.addEventListener('drop',onDrop);\n });\n }\n function hasTooLargeTorrentPreview(){\n // Note: Client-side upload blocking mirrors the server validation and gives feedback before the add request.\n return addPreviewState.items.some(item=>item.xmlrpc_too_large);\n }\n function addTorrentPayload(){\n const fd=new FormData();\n fd.append('uris',$('magnetInput')?.value||'');\n fd.append('directory',$('addPath')?.value||'');\n fd.append('label',$('addLabel')?.value||'');\n fd.append('start',$('addStart')?.checked?'1':'0');\n fd.append('file_priorities',JSON.stringify(collectPreviewPriorities()));\n [...($('torrentFiles')?.files||[])].forEach(f=>fd.append('files',f));\n return fd;\n }\n function resetAddTorrentForm(){\n if($('magnetInput')) $('magnetInput').value='';\n if($('torrentFiles')) $('torrentFiles').value='';\n renderTorrentPreview([]);\n }\n async function addTorrentFromModal(){\n const btn=$('addBtn');\n buttonBusy(btn,true);\n setBusy(true);\n try{\n if(hasTooLargeTorrentPreview()) throw new Error(appMessage('toast.addTooLarge'));\n const res=await fetch('/api/torrents/add',{method:'POST',body:addTorrentPayload()});\n const j=await res.json().catch(()=>({ok:false,error:`Add failed: HTTP ${res.status}`}));\n if(!res.ok || !j.ok) throw new Error(j.error||`Add failed: HTTP ${res.status}`);\n const skipped=(j.skipped_duplicates||[]).length;\n if(skipped) toastMessage('toast.addQueuedSkipped','warning',{count:skipped});\n else toastMessage('toast.addQueued','success');\n resetAddTorrentForm();\n bootstrap.Modal.getInstance($('addModal'))?.hide();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n buttonBusy(btn,false);\n setBusy(false);\n }\n }\n $('addBtn')?.addEventListener('click',addTorrentFromModal); $('torrentFiles')?.addEventListener('change',previewTorrentFiles); $('torrentPreview')?.addEventListener('click',e=>{const card=e.target.closest('.torrent-preview-card'); if(!card) return; if(e.target.closest('.preview-select-all')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=true); if(e.target.closest('.preview-select-none')) card.querySelectorAll('.preview-file-priority').forEach(x=>x.checked=false);});\n"; diff --git a/pytorrent/static/js/torrentDetails.js b/pytorrent/static/js/torrentDetails.js index 38be41d..760cd0f 100644 --- a/pytorrent/static/js/torrentDetails.js +++ b/pytorrent/static/js/torrentDetails.js @@ -1 +1 @@ -export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
${label}${value}
`).join('');\n $('detailPane').innerHTML=`\n
\n
\n
${esc(t.name||'-')}
${esc(t.status||'-')}
\n
Directory${esc(t.path||'-')}
\n
Full data path${esc(fullPath)}
\n
\n
Hash${esc(t.hash||'-')}
\n
\n
${cards}
\n
Labels${labels}
Ratio rule${ratioGroup}
Message${esc(t.message||'-')}
`;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download-link`).catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip/link`, {indexes});\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
${esc(label)}${mediaInfoValue(value)}
`).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
Detected metadata
${rows}
`;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
      ${raw || '
    • No metadata was returned for the sampled part of this file.
    • '}
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function mediaInfoPdfUrl(info){\n // Note: PDF preview links are created by the backend as short-lived app URLs, so the new-tab button does not expose /api/.\n return String(info.preview_url || '');\n }\n function renderPdfPreview(info){\n // Note: PDF preview uses the browser renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = ``;\n const openButton = src ? ` Open in new tab` : '';\n if(!src){\n return `
    PDF preview unavailableMissing temporary app link for inline preview.
    ${downloadButton}
    `;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n const expires = info.preview_expires_in ? ` · temporary link: ${Math.round(Number(info.preview_expires_in) / 60)} min` : '';\n return `
    PDF preview${esc(size)} · rendered by your browser${esc(expires)}
    ${openButton}${downloadButton}
    Inline PDF preview is not availableYour browser blocked the embedded viewer. Open it in a new tab or download the file.
    ${openButton}${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const button = document.querySelector(`#detailPane .file-media-info[data-index=\"${CSS.escape(String(index))}\"]`);\n if(button?.disabled){\n return toast('File info is available after this file is fully downloaded.','warning');\n }\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function fileInfoAvailable(f){\n // Note: File info is intentionally locked until rTorrent reports the selected file as fully downloaded.\n const size = Number(f?.size || 0);\n const progress = Number(f?.progress || 0);\n const completedChunks = Number(f?.completed_chunks || 0);\n const sizeChunks = Number(f?.size_chunks || 0);\n return size <= 0 || progress >= 100 || (sizeChunks > 0 && completedChunks >= sizeChunks);\n }\n function renderFileInfoButton(f){\n const available = fileInfoAvailable(f);\n const title = available ? 'File info / preview' : 'File info is available after this file is fully downloaded.';\n const disabled = available ? '' : ' disabled aria-disabled=\"true\"';\n const stateClass = available ? 'btn-outline-info' : 'btn-outline-secondary file-media-info-blocked';\n return ``;\n }\n function filesNeedAutoRefresh(files){\n // Note: The files list keeps refreshing only while at least one visible file is not fully downloaded.\n return (files || []).some(file => !fileInfoAvailable(file));\n }\n function clearFilesAutoRefresh(){\n // Note: Clearing the timer prevents hidden Files tabs and completed torrents from polling rTorrent.\n if(filesRefreshTimer) clearInterval(filesRefreshTimer);\n filesRefreshTimer = null;\n filesAutoRefreshHash = null;\n }\n function setupFilesAutoRefresh(files){\n // Note: Auto-refresh belongs to the open Files tab and is disabled as soon as all files reach 100%.\n const hash = selectedHash;\n if(activeTab() !== 'files' || !hash || !filesNeedAutoRefresh(files)){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshTimer && filesAutoRefreshHash === hash) return;\n clearFilesAutoRefresh();\n filesAutoRefreshHash = hash;\n filesRefreshTimer = setInterval(async () => {\n if(activeTab() !== 'files' || !selectedHash || filesAutoRefreshHash !== selectedHash){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshInFlight) return;\n filesRefreshInFlight = true;\n try{\n await loadDetails('files', {silent: true});\n }finally{\n filesRefreshInFlight = false;\n }\n }, FILES_AUTO_REFRESH_SECONDS * 1000);\n }\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    ${renderFileInfoButton(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent. File info becomes available only after a file reaches 100%.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n setupFilesAutoRefresh(files);\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=``; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `
    ${esc(label)}${mobileDetailValue(value)}
    `).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')}`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '
    No peers returned by rTorrent.
    ';\n return responsiveTable(headers, rows, 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `
    ${renderFileInfoButton(file)}
    `;\n return [\n `${esc(file.path || file.name || '-')}`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)}`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '
    No files returned by rTorrent.
    ';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerItem(t){\n return `
  • ${esc(t.url || '-')}Seeds / Peers: ${esc(trackerSeedsPeers(t))}
  • `;\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `
    ${esc(title)}${meta ? `${esc(meta)}` : ''}
    `;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `
    ${titleMarkup}${body}
    `;\n }\n return `
    ${titleMarkup}${body}
    `;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    Torrent details
    Loading torrent details...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerList = trackers.slice(0, 12).map(mobileDetailsTrackerItem).join('') || '
  • No trackers returned by rTorrent.
  • ';\n const generalBody = `
    ${esc(t.name || '-')}
    Path${esc(fullPath)}
    Hash${esc(t.hash || '-')}
    ${mobileDetailsStatCards(t)}
    `;\n const messageBody = `
    ${esc(t.message || 'No message.')}
    `;\n const errorBox = failures.length ? `
    Partial details loaded
    ${esc(failures.join(' | '))}
    ` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', ``, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '
    Loading peers, files and trackers...
    ';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `
    Details failed
    ${esc(e.message)}
    `;\n }\n }\n\n async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`
    Loading ${esc(tab)}...
    `;\n try{\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab()) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`
    ${esc(e.message)}
    `;\n }\n }\n"; +export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
    ${label}${value}
    `).join('');\n $('detailPane').innerHTML=`\n
    \n
    \n
    ${esc(t.name||'-')}
    ${esc(t.status||'-')}
    \n
    Directory${esc(t.path||'-')}
    \n
    Full data path${esc(fullPath)}
    \n
    \n
    Hash${esc(t.hash||'-')}
    \n
    \n
    ${cards}
    \n
    Labels${labels}
    Ratio rule${ratioGroup}
    Message${esc(t.message||'-')}
    `;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download-link`).catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip/link`, {indexes});\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
    ${esc(label)}${mediaInfoValue(value)}
    `).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
    Detected metadata
    ${rows}
    `;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
      ${raw || '
    • No metadata was returned for the sampled part of this file.
    • '}
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function mediaInfoPdfUrl(info){\n // Note: PDF preview links are created by the backend as short-lived app URLs, so the new-tab button does not expose /api/.\n return String(info.preview_url || '');\n }\n function renderPdfPreview(info){\n // Note: PDF preview uses the browser renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = ``;\n const openButton = src ? ` Open in new tab` : '';\n if(!src){\n return `
    PDF preview unavailableMissing temporary app link for inline preview.
    ${downloadButton}
    `;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n const expires = info.preview_expires_in ? ` · temporary link: ${Math.round(Number(info.preview_expires_in) / 60)} min` : '';\n return `
    PDF preview${esc(size)} · rendered by your browser${esc(expires)}
    ${openButton}${downloadButton}
    Inline PDF preview is not availableYour browser blocked the embedded viewer. Open it in a new tab or download the file.
    ${openButton}${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const button = document.querySelector(`#detailPane .file-media-info[data-index=\"${CSS.escape(String(index))}\"]`);\n if(button?.disabled){\n return toast('File info is available after this file is fully downloaded.','warning');\n }\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function fileInfoAvailable(f){\n // Note: File info is intentionally locked until rTorrent reports the selected file as fully downloaded.\n const size = Number(f?.size || 0);\n const progress = Number(f?.progress || 0);\n const completedChunks = Number(f?.completed_chunks || 0);\n const sizeChunks = Number(f?.size_chunks || 0);\n return size <= 0 || progress >= 100 || (sizeChunks > 0 && completedChunks >= sizeChunks);\n }\n function renderFileInfoButton(f){\n const available = fileInfoAvailable(f);\n const title = available ? 'File info / preview' : 'File info is available after this file is fully downloaded.';\n const disabled = available ? '' : ' disabled aria-disabled=\"true\"';\n const stateClass = available ? 'btn-outline-info' : 'btn-outline-secondary file-media-info-blocked';\n return ``;\n }\n function filesNeedAutoRefresh(files){\n // Note: The files list keeps refreshing only while at least one visible file is not fully downloaded.\n return (files || []).some(file => !fileInfoAvailable(file));\n }\n function clearFilesAutoRefresh(){\n // Note: Clearing the timer prevents hidden Files tabs and completed torrents from polling rTorrent.\n if(filesRefreshTimer) clearInterval(filesRefreshTimer);\n filesRefreshTimer = null;\n filesAutoRefreshHash = null;\n }\n function setupFilesAutoRefresh(files){\n // Note: Auto-refresh belongs to the open Files tab and is disabled as soon as all files reach 100%.\n const hash = selectedHash;\n if(activeTab() !== 'files' || !hash || !filesNeedAutoRefresh(files)){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshTimer && filesAutoRefreshHash === hash) return;\n clearFilesAutoRefresh();\n filesAutoRefreshHash = hash;\n filesRefreshTimer = setInterval(async () => {\n if(activeTab() !== 'files' || !selectedHash || filesAutoRefreshHash !== selectedHash){\n clearFilesAutoRefresh();\n return;\n }\n if(filesRefreshInFlight) return;\n filesRefreshInFlight = true;\n try{\n await loadDetails('files', {silent: true});\n }finally{\n filesRefreshInFlight = false;\n }\n }, FILES_AUTO_REFRESH_SECONDS * 1000);\n }\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    ${renderFileInfoButton(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent. File info becomes available only after a file reaches 100%.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n setupFilesAutoRefresh(files);\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=``; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `
    ${esc(label)}${mobileDetailValue(value)}
    `).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')}`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '
    No peers returned by rTorrent.
    ';\n return responsiveTable(headers, rows, 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `
    ${renderFileInfoButton(file)}
    `;\n return [\n `${esc(file.path || file.name || '-')}`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)}`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '
    No files returned by rTorrent.
    ';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerItem(t){\n return `
  • ${esc(t.url || '-')}Seeds / Peers: ${esc(trackerSeedsPeers(t))}
  • `;\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `
    ${esc(title)}${meta ? `${esc(meta)}` : ''}
    `;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `
    ${titleMarkup}${body}
    `;\n }\n return `
    ${titleMarkup}${body}
    `;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    Torrent details
    Loading torrent details...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerList = trackers.slice(0, 12).map(mobileDetailsTrackerItem).join('') || '
  • No trackers returned by rTorrent.
  • ';\n const generalBody = `
    ${esc(t.name || '-')}
    Path${esc(fullPath)}
    Hash${esc(t.hash || '-')}
    ${mobileDetailsStatCards(t)}
    `;\n const messageBody = `
    ${esc(t.message || 'No message.')}
    `;\n const errorBox = failures.length ? `
    Partial details loaded
    ${esc(failures.join(' | '))}
    ` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', ``, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '
    Loading peers, files and trackers...
    ';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `
    Details failed
    ${esc(e.message)}
    `;\n }\n }\n\n async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`
    Loading ${esc(tab)}...
    `;\n try{\n const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`;\n const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}});\n const text=await res.text();\n let json;\n try{\n json=JSON.parse(text);\n }catch(parseErr){\n throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`);\n if(tab!==activeTab()) return;\n if(tab==='files') renderFiles(json.files||[]);\n if(tab==='chunks') renderChunks(json.chunks||{});\n if(tab==='peers') renderPeers(json.peers||[]);\n if(tab==='trackers') renderTrackers(json.trackers||[]);\n }catch(e){\n if(!silent) pane.innerHTML=`
    ${esc(e.message)}
    `;\n }\n }\n";