diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py
index 2c57714..833bbc3 100644
--- a/pytorrent/services/smart_queue.py
+++ b/pytorrent/services/smart_queue.py
@@ -311,6 +311,33 @@ def count_history(profile_id: int, user_id: int | None = None) -> int:
).fetchone()
return int((row or {}).get('count') or 0)
+
+def _latest_history_event(profile_id: int, user_id: int | None = None) -> str:
+ """Return the newest Smart Queue history event for duplicate suppression."""
+ # Note: Disabled Smart Queue should leave one waiting marker, not a poller-generated log stream.
+ user_id = user_id or default_user_id()
+ with connect() as conn:
+ row = conn.execute(
+ 'SELECT event FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT 1',
+ (user_id, profile_id),
+ ).fetchone()
+ return str((row or {}).get('event') or '')
+
+
+def _record_disabled_waiting_once(profile_id: int, user_id: int, details: dict[str, Any] | None = None) -> bool:
+ """Record one disabled-state history row until Smart Queue runs or changes state again."""
+ # Note: This keeps the UI audit trail useful without creating repeated disabled logs on every poll.
+ if _latest_history_event(profile_id, user_id) in {'disabled_waiting_start', 'auto_stopped_idle'}:
+ return False
+ payload = {
+ 'decision': 'Smart Queue disabled, waiting for start',
+ 'enabled': False,
+ **(details or {}),
+ }
+ add_history(profile_id, 'disabled_waiting_start', [], [], 0, payload, user_id)
+ return True
+
+
def _excluded_hashes(profile_id: int, user_id: int) -> set[str]:
return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)}
@@ -1105,8 +1132,9 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), True)
except Exception:
restored = []
- add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False, 'labels_restored': restored}, user_id)
- return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'}
+ # Note: Disabled checks are frequent poller passes; record only the first waiting-state row.
+ disabled_log_recorded = _record_disabled_waiting_once(profile_id, user_id, {'labels_restored': restored})
+ return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'disabled_log_recorded': disabled_log_recorded, 'message': 'Smart Queue disabled, waiting for start'}
torrents = rtorrent.list_torrents(profile)
# Note: Stalled labels block automatic starting only; a manually started Stalled item still counts as a running slot.
diff --git a/pytorrent/static/js/smartQueue.js b/pytorrent/static/js/smartQueue.js
index 09b3190..4abfe6a 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: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 `${esc(t.name||hash)} ${esc(smartQueueTorrentLabel(t))} `;\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),` remove exception `]),'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(h.event||d.decision||'-'),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?` ${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)}) `:'';\n const clear=totalHistory?` Clear history `:'';\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 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.
Copy
`;\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}
Delete `;\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 and revoked 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 uses the existing DELETE API and refreshes both token and user counts.\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 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 if($('authProfile')) $('authProfile').innerHTML=`All profiles `+profiles.map(p=>`${esc(p.name)} `).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=` Generate token Tokens Remove `;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),`${esc(tokenText)} `,actions];\n });\n $('authUsersManager').innerHTML=rows.length?table(['User','Role','Active','Profile rights','API tokens','Actions'],rows):'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||'',password:$('authPassword')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\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 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}${esc(f.label)} ${esc(f.key)}${note} ${valueNote} ${input} `;\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 async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\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 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=' Clear history
';\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
Remove
`;\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 cache Clears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Clear profile cacheLogs and history Pending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
Clear job logs Clear Smart Queue logs Clear operation logs Clear Planner logs Clear automation logs Clear all logs
Refresh
`;\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 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 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 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); 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=`${fallback} `+diskMonitorPaths.map(p=>`${esc(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'}
Use
`).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)=>`${p[1]} `).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=>`${p[1]} `).join('')} ${panes.map(p=>``).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=` ${label} `;\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 `${label} `;\n }\n function plannerToggleRow(id,title,description){\n return `${title} ${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return ``;\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 `${esc(t.name||hash)} ${esc(smartQueueTorrentLabel(t))} `;\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),` remove exception `]),'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?` ${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)}) `:'';\n const clear=totalHistory?` Clear history `:'';\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 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.
Copy
`;\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}
Delete `;\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 and revoked 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 uses the existing DELETE API and refreshes both token and user counts.\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 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 if($('authProfile')) $('authProfile').innerHTML=`All profiles `+profiles.map(p=>`${esc(p.name)} `).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=` Generate token Tokens Remove `;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),`${esc(tokenText)} `,actions];\n });\n $('authUsersManager').innerHTML=rows.length?table(['User','Role','Active','Profile rights','API tokens','Actions'],rows):'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||'',password:$('authPassword')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\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 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}${esc(f.label)} ${esc(f.key)}${note} ${valueNote} ${input} `;\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 async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\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 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=' Clear history
';\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
Remove
`;\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 cache Clears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Clear profile cacheLogs and history Pending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
Clear job logs Clear Smart Queue logs Clear operation logs Clear Planner logs Clear automation logs Clear all logs
Refresh
`;\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 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 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 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); 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=`${fallback} `+diskMonitorPaths.map(p=>`${esc(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'}
Use
`).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)=>`${p[1]} `).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=>`${p[1]} `).join('')} ${panes.map(p=>``).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=` ${label} `;\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 `${label} `;\n }\n function plannerToggleRow(id,title,description){\n return `${title} ${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return ``;\n }\n";
diff --git a/pytorrent/static/styles.original.css b/pytorrent/static/styles.original.css
deleted file mode 100644
index 7e260ef..0000000
--- a/pytorrent/static/styles.original.css
+++ /dev/null
@@ -1,3986 +0,0 @@
-:root {
- --app-font-family:
- Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
- --ui-scale: 1;
- --topbar: calc(50px * var(--ui-scale));
- --statusbar: calc(34px * var(--ui-scale));
- --mobile-filterbar-height: 132px;
- --sidebar: calc(270px * var(--ui-scale));
- --torrent-progress-complete: #198754;
-}
-[data-bs-theme="dark"] {
- --bs-body-bg: #05070a;
- --bs-body-bg-rgb: 5, 7, 10;
- --bs-body-color: #d6dde8;
- --bs-secondary-bg: #0a0f16;
- --bs-secondary-bg-rgb: 10, 15, 22;
- --bs-tertiary-bg: #0e141d;
- --bs-border-color: #1d2734;
- --bs-secondary-color: #8d98aa;
- --bs-primary-bg-subtle: #0d2238;
- --bs-primary-text-emphasis: #9ecbff;
- --torrent-progress-complete: #2f9e75;
-}
-
-html[data-app-font="adwaita-mono"] {
- --app-font-family:
- "Adwaita Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
- "Liberation Mono", monospace;
-}
-html[data-app-font="inter"] {
- --app-font-family:
- Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
-}
-html[data-app-font="system-ui"] {
- --app-font-family:
- system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
- Roboto, Arial, sans-serif;
-}
-html[data-app-font="figtree"] {
- --app-font-family:
- Figtree, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
- sans-serif;
-}
-html[data-app-font="geist"] {
- --app-font-family:
- Geist, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
- sans-serif;
-}
-html[data-app-font="manrope"] {
- --app-font-family:
- Manrope, Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
- sans-serif;
-}
-html[data-app-font="dm-sans"] {
- --app-font-family:
- "DM Sans", Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
- sans-serif;
-}
-html[data-app-font="source-sans-3"] {
- --app-font-family:
- "Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI,
- Roboto, Arial, sans-serif;
-}
-html[data-app-font="open-sans"] {
- --app-font-family:
- "Open Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
- sans-serif;
-}
-html[data-app-font="roboto"] {
- --app-font-family:
- Roboto, system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
-}
-html[data-app-font="lato"] {
- --app-font-family:
- Lato, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
-}
-html[data-app-font="nunito-sans"] {
- --app-font-family:
- "Nunito Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
- sans-serif;
-}
-html[data-app-font="poppins"] {
- --app-font-family:
- Poppins, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
-}
-html[data-app-font="montserrat"] {
- --app-font-family:
- Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, Arial,
- sans-serif;
-}
-html[data-app-font="ibm-plex-sans"] {
- --app-font-family:
- "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, Arial,
- sans-serif;
-}
-html[data-app-font="jetbrains-mono"] {
- --app-font-family:
- "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
- "Liberation Mono", monospace;
-}
-html,
-body {
- height: 100%;
-}
-body {
- overflow: hidden;
- overflow-x: hidden;
- font-size: calc(13px * var(--ui-scale));
- min-height: 100vh;
- min-height: 100dvh;
- padding: calc(8px * var(--ui-scale));
- background: #05070a;
- font-family: var(--app-font-family);
-}
-.app-shell {
- height: calc(100vh - (16px * var(--ui-scale)));
- height: calc(100dvh - (16px * var(--ui-scale)));
- display: grid;
- grid-template-rows: var(--topbar) 1fr var(--statusbar);
- background: var(--bs-body-bg);
- border: 1px solid var(--bs-border-color);
- border-radius: 12px;
- overflow: hidden;
- box-shadow: 0 12px 45px rgba(0, 0, 0, 0.38);
-}
-.topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.75rem;
- padding: 0.42rem 0.7rem;
- min-height: var(--topbar);
- background: var(--bs-secondary-bg);
-}
-.toolbar-left,
-.toolbar-right {
- display: flex;
- align-items: center;
- gap: 0.45rem;
- min-width: 0;
-}
-.toolbar-left {
- flex: 0 1 auto;
- overflow: hidden;
-}
-.toolbar-right {
- flex: 1 1 0;
- justify-content: flex-end;
- margin-left: auto;
-}
-.brand {
- font-weight: 800;
- font-size: 1.05rem;
- letter-spacing: 0.2px;
- white-space: nowrap;
- line-height: 32px;
-}
-.profile-picker-btn {
- max-width: 180px;
-}
-.profile-picker-btn span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.profile-select {
- width: 100%;
-}
-.search {
- width: min(38vw, 420px);
- min-width: clamp(160px, 20vw, 220px);
- max-width: 420px;
- flex: 0 1 420px;
-}
-.mobile-speed-stats {
- display: none;
- align-items: center;
- gap: 0.45rem;
- flex: 0 0 auto;
- color: var(--bs-secondary-color);
- font-size: 0.72rem;
- white-space: nowrap;
-}
-.mobile-speed-stats b {
- color: var(--bs-body-color);
- font-weight: 700;
-}
-.mobile-speed-stats span {
- display: inline-flex;
- align-items: center;
- gap: 0.18rem;
-}
-.topbar .form-control,
-.topbar .form-select {
- height: 32px;
- line-height: 1.15;
-}
-.topbar .btn {
- min-height: 28px;
- line-height: 1;
-}
-#themeToggle,
-#mobileToggle {
- width: 32px;
- min-width: 32px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
-}
-.spinner-border-xs {
- width: 0.75rem;
- height: 0.75rem;
- border-width: 0.12em;
- vertical-align: -1px;
-}
-.global-loader {
- position: fixed;
- right: 14px;
- bottom: 44px;
- z-index: 7000;
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- padding: 0.4rem 0.65rem;
- border-radius: 999px;
- background: var(--bs-tertiary-bg);
- color: var(--bs-body-color);
- border: 1px solid var(--bs-border-color);
- box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
-}
-
-.initial-loader {
- position: fixed;
- inset: 0;
- z-index: 9000;
- display: grid;
- place-items: center;
- padding: 1rem;
- background: radial-gradient(
- circle at 50% 35%,
- rgba(var(--bs-secondary-bg-rgb), 0.98),
- var(--bs-body-bg) 68%
- );
- color: var(--bs-body-color);
- transition:
- opacity 0.22s ease,
- visibility 0.22s ease;
-}
-.initial-loader.is-hidden {
- opacity: 0;
- visibility: hidden;
- pointer-events: none;
-}
-.initial-loader-card {
- width: min(92vw, 430px);
- padding: 2rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 18px;
- background: rgba(var(--bs-secondary-bg-rgb), 0.88);
- box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48);
- text-align: center;
-}
-.initial-loader-brand {
- font-size: 1.35rem;
- font-weight: 800;
- letter-spacing: 0.2px;
-}
-.initial-loader-spinner {
- margin: 1.4rem 0 1rem;
-}
-.initial-loader-title {
- font-size: 1rem;
- font-weight: 700;
-}
-.initial-loader-text {
- margin-top: 0.35rem;
- color: var(--bs-secondary-color);
-}
-
-.main-grid {
- min-height: 0;
- display: grid;
- grid-template-columns: var(--sidebar) 1fr;
-}
-/* Note: Sidebar filters are denser so large tracker lists fit better on one screen. */
-.sidebar {
- padding: 0.5rem;
- overflow: auto;
- background: rgba(var(--bs-secondary-bg-rgb), 0.9);
-}
-.filter {
- width: 100%;
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 0.1rem 0.45rem;
- align-items: center;
- margin-bottom: 0.12rem;
- padding: 0.34rem 0.5rem;
- border: 0;
- border-radius: 0.55rem;
- background: transparent;
- color: var(--bs-body-color);
- text-align: left;
-}
-.filter:hover,
-.filter.active {
- background: var(--bs-primary-bg-subtle);
- color: var(--bs-primary-text-emphasis);
-}
-.filter > span:first-child {
- min-width: 0;
- font-weight: 600;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.filter > span:last-child {
- min-width: 0;
- max-width: 12rem;
- text-align: right;
-}
-.filter-count {
- display: block;
- font-weight: 700;
- line-height: 1.1;
-}
-.filter-meta {
- display: block;
- margin-top: 0.05rem;
- color: var(--bs-secondary-color);
- font-size: 0.68rem;
- font-weight: 400;
- line-height: 1.15;
- opacity: 0.72;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.filter.active .filter-meta,
-.filter:hover .filter-meta {
- color: var(--bs-primary-text-emphasis);
- opacity: 0.78;
-}
-.shortcut {
- font-size: 0.78rem;
- color: var(--bs-secondary-color);
- padding: 0.15rem 0.5rem;
-}
-.content {
- min-width: 0;
- min-height: 0;
- display: grid;
- grid-template-rows: minmax(0, 1fr) 7px var(--detail-panel-height, 255px);
- position: relative;
-}
-.table-wrap {
- contain: content;
- overflow: auto;
- position: relative;
-}
-.torrent-table {
- margin: 0;
- white-space: nowrap;
- table-layout: auto;
-}
-.torrent-table thead th {
- position: sticky;
- top: 0;
- z-index: 2;
- background: var(--bs-tertiary-bg);
- border-bottom: 1px solid var(--bs-border-color);
- user-select: none;
-}
-.torrent-table thead th[data-sort] {
- cursor: pointer;
-}
-.torrent-table thead th[data-sort]:hover,
-.torrent-table thead th.sorted {
- color: var(--bs-primary-text-emphasis);
-}
-.sort-icon {
- opacity: 0.85;
-}
-.torrent-table tbody tr {
- cursor: default;
- height: 32px;
-}
-.torrent-table > :not(caption) > * > * {
- padding-bottom: 0.22rem;
- padding-top: 0.22rem;
- vertical-align: middle;
-}
-.torrent-table .message {
- max-width: 320px;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.torrent-table tbody tr.selected td {
- background: var(--bs-primary-bg-subtle);
-}
-.torrent-table .sel {
- width: 34px;
- text-align: center;
-}
-.torrent-table .name {
- min-width: 280px;
- max-width: 520px;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.torrent-table .path {
- max-width: 360px;
- overflow: hidden;
- text-overflow: ellipsis;
- color: var(--bs-secondary-color);
-}
-.virtual-spacer td {
- padding: 0 !important;
- border: 0 !important;
-}
-.empty {
- height: 120px;
- text-align: center;
- vertical-align: middle;
- color: var(--bs-secondary-color);
-}
-.progress.thin {
- height: 7px;
- min-width: 130px;
- margin-bottom: 1px;
- background: rgba(255, 255, 255, 0.08);
-}
-.details {
- grid-row: 3;
- grid-column: 1;
- min-height: 0;
- overflow: hidden;
- background: rgba(var(--bs-secondary-bg-rgb), 0.78);
-}
-.detail-pane {
- height: calc(var(--detail-panel-height, 255px) - 45px);
- overflow: auto;
- padding: 0.5rem 0.65rem;
-}
-.detail-resize-handle {
- grid-row: 2;
- grid-column: 1;
- align-items: center;
- background: rgba(var(--bs-secondary-bg-rgb), 0.72);
- cursor: row-resize;
- display: flex;
- justify-content: center;
- min-height: 7px;
- position: relative;
- z-index: 3;
-}
-.detail-resize-handle::before {
- background: var(--bs-border-color);
- border-radius: 999px;
- content: '';
- height: 3px;
- width: 46px;
-}
-.detail-resize-handle:hover::before,
-body.resizing-details .detail-resize-handle::before {
- background: var(--bs-primary);
-}
-body.resizing-details {
- cursor: row-resize;
- user-select: none;
-}
-.loading-line {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--bs-secondary-color);
- padding: 0.75rem;
-}
-.muted-pane {
- color: var(--bs-secondary-color);
-}
-.detail-table {
- white-space: nowrap;
-}
-.responsive-table-wrap {
- max-width: 100%;
- overflow-x: auto;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- -webkit-overflow-scrolling: touch;
-}
-.responsive-table-wrap .detail-table {
- margin-bottom: 0;
-}
-.smart-exclusions-table {
- min-width: 680px;
-}
-.smart-history-table {
- min-width: 760px;
- table-layout: fixed;
-}
-.smart-history-table th,
-.smart-history-table td {
- overflow-wrap: anywhere;
- white-space: normal;
-}
-.general-summary,
-.general-grid,
-.general-meta {
- display: grid;
- gap: 0.75rem;
-}
-
-.general-summary {
- grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr);
- margin-bottom: 0.75rem;
-}
-
-.general-summary-main,
-.general-summary-side,
-.general-stat,
-.general-meta > div {
- background: var(--bs-body-bg);
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- min-width: 0;
- padding: 0.75rem;
-}
-
-.general-title-row {
- align-items: flex-start;
- display: flex;
- gap: 0.75rem;
- justify-content: space-between;
-}
-
-.general-title-row h6 {
- font-size: 1rem;
- line-height: 1.35;
- margin: 0;
- overflow-wrap: anywhere;
-}
-
-.general-path {
- display: grid;
- gap: 0.15rem;
- margin-top: 0.5rem;
- overflow-wrap: anywhere;
-}
-
-.general-path b {
- color: var(--bs-secondary-color);
- font-size: 0.72rem;
- letter-spacing: 0.03em;
- text-transform: uppercase;
-}
-
-.general-path span {
- font-size: 0.82rem;
-}
-
-
-.general-summary-side code {
- display: block;
- font-size: 0.78rem;
- overflow-wrap: anywhere;
- white-space: normal;
-}
-
-.general-grid {
- grid-template-columns: repeat(5, minmax(0, 1fr));
-}
-
-.general-meta {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- margin-top: 0.75rem;
-}
-
-.general-stat b,
-.general-meta b,
-.general-summary-side b {
- color: var(--bs-secondary-color);
- display: block;
- font-size: 0.72rem;
- letter-spacing: 0.03em;
- margin-bottom: 0.25rem;
- text-transform: uppercase;
-}
-
-.general-stat span,
-.general-meta span {
- display: block;
- overflow-wrap: anywhere;
-}
-.statusbar {
- display: flex;
- align-items: center;
- gap: 1rem;
- padding: 0 0.75rem;
- overflow-x: auto;
- background: var(--bs-tertiary-bg);
- color: var(--bs-secondary-color);
- white-space: nowrap;
-}
-.statusbar b {
- color: var(--bs-body-color);
-}
-.speed-peaks {
- display: inline-flex;
- align-items: center;
- gap: 0.25rem;
-}
-.status-limit {
- border: 1px solid var(--bs-border-color);
- background: rgba(var(--bs-secondary-bg-rgb), 0.9);
- color: var(--bs-secondary-color);
- border-radius: 0.45rem;
- padding: 0.12rem 0.5rem;
- white-space: nowrap;
-}
-.status-limit:hover {
- color: var(--bs-body-color);
- background: var(--bs-secondary-bg);
-}
-.ctx-menu {
- display: none;
- position: absolute;
- z-index: 5000;
- min-width: 200px;
- padding: 0.35rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- background: var(--bs-body-bg);
-}
-.ctx-menu button {
- display: block;
- width: 100%;
- text-align: left;
- border: 0;
- background: transparent;
- color: var(--bs-body-color);
- padding: 0.42rem 0.55rem;
- border-radius: 0.4rem;
-}
-.ctx-menu button:hover {
- background: var(--bs-secondary-bg);
-}
-.ctx-menu .danger {
- color: var(--bs-danger);
-}
-.ctx-menu hr {
- margin: 0.25rem 0;
- border-color: var(--bs-border-color);
-}
-.profile-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 0.25rem 0.5rem;
- align-items: center;
- margin-bottom: 0.45rem;
- padding: 0.45rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.58);
-}
-.profile-row.active {
- border-color: var(--bs-primary);
- background: var(--bs-primary-bg-subtle);
-}
-.profile-row span {
- grid-column: 1 / 2;
- color: var(--bs-secondary-color);
- overflow-wrap: anywhere;
-}
-.profile-actions,
-.profile-form-actions {
- display: inline-flex;
- gap: 0.35rem;
- flex-wrap: wrap;
-}
-.profile-form-grid {
- display: grid;
- grid-template-columns: minmax(150px, 1.1fr) minmax(260px, 2.1fr) minmax(
- 90px,
- 0.55fr
- ) minmax(120px, 0.75fr) minmax(145px, auto) auto;
- gap: 0.65rem;
- align-items: start;
-}
-.profile-form-field {
- display: grid;
- gap: 0.25rem;
- min-width: 0;
-}
-.profile-form-field > span:first-child {
- color: var(--bs-secondary-color);
- font-size: 0.72rem;
- font-weight: 700;
- line-height: 1.1;
- text-transform: uppercase;
-}
-.profile-form-field small {
- color: var(--bs-secondary-color);
- line-height: 1.25;
-}
-.profile-check-field .form-check {
- min-height: 31px;
- display: flex;
- align-items: center;
- gap: 0.45rem;
-}
-.flag-icon {
- border-radius: 2px;
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12);
-}
-.flag-code {
- color: var(--bs-secondary-color);
- margin-left: 0.25rem;
-}
-.modal-content {
- background: var(--bs-body-bg);
- border: 1px solid var(--bs-border-color);
- border-radius: 14px;
-}
-.modal-header,
-.modal-footer {
- background: rgba(var(--bs-secondary-bg-rgb), 0.82);
- border-color: var(--bs-border-color);
-}
-.add-grid {
- display: grid;
- gap: 0.85rem;
-}
-.magnet-box {
- min-height: 92px;
- resize: vertical;
-}
-.upload-box,
-.surface-section {
- border: 1px solid var(--bs-border-color);
- background: rgba(var(--bs-secondary-bg-rgb), 0.5);
- border-radius: 0.75rem;
- padding: 0.75rem;
-}
-.section-title {
- font-weight: 700;
- margin-bottom: 0.55rem;
- color: var(--bs-body-color);
-}
-.preset-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 0.4rem;
-}
-.toast-host {
- position: fixed;
- right: 14px;
- top: 70px;
- z-index: 8000;
- display: grid;
- gap: 0.4rem;
-}
-.toast-item {
- display: flex;
- align-items: center;
- gap: 0.45rem;
- max-width: 360px;
- padding: 0.45rem 0.65rem;
- border-radius: 0.55rem;
- box-shadow: 0 8px 25px rgba(0, 0, 0, 0.28);
-}
-
-.toast-message {
- min-width: 0;
- overflow-wrap: anywhere;
-}
-
-.toast-count {
- flex: 0 0 auto;
- padding: 0.05rem 0.35rem;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.22);
- font-size: 0.78rem;
- font-weight: 700;
-}
-@media (max-width: 1100px) {
- :root {
- --topbar: 88px;
- }
- .topbar {
- align-items: flex-start;
- flex-wrap: wrap;
- }
- .toolbar-left {
- flex: 1 1 100%;
- overflow: visible;
- flex-wrap: wrap;
- }
- .toolbar-right {
- flex: 1 1 100%;
- justify-content: flex-end;
- }
- .search {
- flex: 1 1 220px;
- width: auto;
- min-width: 160px;
- max-width: none;
- }
-}
-@media (max-width: 900px) {
- :root {
- --sidebar: 0px;
- }
- .sidebar {
- display: none;
- }
- .general-summary,
- .general-grid,
- .general-meta {
- grid-template-columns: 1fr;
- }
-}
-@media (max-width: 640px) {
- :root {
- --topbar: 132px;
- }
- .preset-grid {
- grid-template-columns: 1fr 1fr;
- }
-}
-
-.job-settings-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(220px, 1fr));
- gap: 0.75rem;
-}
-.job-settings-actions {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
-}
-
-.preferences-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(220px, 1fr));
- gap: 0.75rem;
-}
-.form-field {
- display: grid;
- gap: 0.3rem;
-}
-.form-field > span {
- color: var(--bs-secondary-color);
- font-size: 0.78rem;
- font-weight: 700;
- text-transform: uppercase;
-}
-
-@media (max-width: 640px) {
- .job-settings-grid,
- .preferences-grid {
- grid-template-columns: 1fr;
- }
-}
-
-.date-compact {
- white-space: nowrap;
-}
-.btn-xs {
- --bs-btn-padding-y: 0.18rem;
- --bs-btn-padding-x: 0.42rem;
- --bs-btn-font-size: 0.78rem;
- --bs-btn-border-radius: 0.35rem;
-}
-.nav-btn {
- border-radius: 0.45rem !important;
- margin: 0 !important;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.25rem;
-}
-.nav-btn + .nav-btn,
-.torrent-action + .torrent-action {
- margin-left: 0.08rem !important;
-}
-.path-list {
- height: 360px;
- overflow: auto;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.35);
-}
-.path-row {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.42rem 0.6rem;
- border-bottom: 1px solid var(--bs-border-color);
- cursor: pointer;
-}
-.path-row:hover {
- background: var(--bs-primary-bg-subtle);
- color: var(--bs-primary-text-emphasis);
-}
-.chips {
- display: flex;
- gap: 0.35rem;
- flex-wrap: wrap;
-}
-.chip {
- border: 1px solid var(--bs-border-color);
- background: rgba(var(--bs-secondary-bg-rgb), 0.6);
- color: var(--bs-body-color);
- border-radius: 999px;
- padding: 0.22rem 0.6rem;
- font-size: 0.78rem;
-}
-.mobile-list {
- overflow: auto;
- padding: 0.55rem;
- background: var(--bs-body-bg);
-}
-.mobile-card {
- border: 1px solid var(--bs-border-color);
- background: rgba(var(--bs-secondary-bg-rgb), 0.72);
- border-radius: 0.75rem;
- padding: 0.65rem;
- margin-bottom: 0.55rem;
-}
-.mobile-card.selected {
- outline: 2px solid var(--bs-primary);
-}
-.mobile-card .name {
- font-weight: 700;
- word-break: break-word;
-}
-.mobile-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.35rem;
- margin-top: 0.45rem;
-}
-#systemChart {
- width: 140px;
- height: 24px;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.35rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.85);
-}
-.badge-degraded {
- background: #f59e0b !important;
- color: #111 !important;
-}
-body.mobile-mode .table-wrap,
-body.mobile-mode .detail-resize-handle {
- display: none;
-}
-body.mobile-mode .details {
- display: none !important;
-}
-/* Note: Merged mobile list rules remove duplicate CSS selectors. */
-body.mobile-mode #mobileList {
- display: block !important;
- grid-row: 3;
- min-height: 0;
- height: 100%;
- overflow: auto;
- position: relative;
- z-index: 2;
- padding: 0.55rem 0.55rem 1rem !important;
-}
-body.mobile-mode .content {
- display: grid !important;
- grid-template-rows: auto auto minmax(0, 1fr) !important;
- min-height: 0;
- overflow: hidden;
-}
-body.mobile-mode .torrent-table {
- display: none;
-}
-body.mobile-mode .main-grid {
- min-height: 0;
- overflow: hidden;
-}
-@media (max-width: 640px) {
- .nav-btn span {
- display: none;
- }
-}
-
-.torrent-table td:nth-child(5) {
- min-width: 92px;
- width: 110px;
- white-space: nowrap;
-}
-
-.mobile-sort-row {
- display: flex;
- margin-top: 0.4rem;
- justify-content: flex-end;
- gap: 0.5rem;
-}
-.mobile-sort-row .btn {
- width: 100%;
- justify-content: center;
-}
-
-.view-preferences-note {
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- margin-bottom: 0.75rem;
- padding: 0.65rem 0.75rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.72);
- color: var(--bs-secondary-color);
-}
-.view-preferences-note i {
- color: var(--bs-primary);
-}
-.view-preferences-note span {
- flex: 1 1 260px;
-}
-.view-preferences-note .btn {
- flex: 0 0 auto;
-}
-
-.hidden-col {
- display: none !important;
-}
-.status-docs {
- margin-left: auto;
- color: inherit;
- text-decoration: none;
- font-weight: 600;
- opacity: 0.9;
- white-space: nowrap;
-}
-.status-docs:hover {
- opacity: 1;
- text-decoration: underline;
-}
-.column-check {
- padding: 0.35rem 0.5rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.5rem;
- background: var(--bs-body-bg);
-}
-.label-filters .label-filter,
-.tracker-filters .tracker-filter {
- font-size: 0.78rem;
- margin-bottom: 0.08rem;
- padding: 0.26rem 0.44rem;
-}
-.label-filters .label-filter i,
-.tracker-filters .tracker-filter i {
- opacity: 0.75;
- margin-right: 0.25rem;
-}
-
-.tracker-filters .tracker-filter span:first-child {
- align-items: center;
- display: inline-flex;
- gap: 0.35rem;
- min-width: 0;
-}
-
-.tracker-favicon {
- border-radius: 0.2rem;
- flex: 0 0 auto;
- height: 14px;
- object-fit: contain;
- width: 14px;
-}
-
-.tracker-favicon:not(.d-none) + .tracker-fallback-icon {
- display: none;
-}
-
-.tracker-filter-empty {
- align-items: center;
- color: var(--bs-secondary-color);
- display: flex;
- font-size: 0.76rem;
- gap: 0.3rem;
- padding: 0.2rem 0.44rem;
-}
-
-/* Note: Empty tracker state uses the same sidebar spacing as regular filter rows. */
-.tracker-filter-empty .spinner-border-xs {
- height: 0.65rem;
- width: 0.65rem;
-}
-
-.column-manager {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
- gap: 0.55rem;
-}
-
-.column-card {
- display: flex;
- align-items: center;
- gap: 0.55rem;
- margin: 0;
- padding: 0.55rem 0.65rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.7rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.45);
- cursor: pointer;
- user-select: none;
- transition:
- background 0.15s,
- border-color 0.15s,
- transform 0.15s;
-}
-
-.column-card:hover,
-.column-card.active {
- background: var(--bs-primary-bg-subtle);
-}
-
-.column-card:hover {
- border-color: var(--bs-primary);
-}
-
-.column-card.active {
- border-color: rgba(var(--bs-primary-rgb), 0.55);
-}
-
-.column-card .form-check-input {
- margin: 0;
-}
-
-.column-card .form-check-label {
- display: flex;
- align-items: center;
- gap: 0.45rem;
- font-weight: 600;
-}
-
-.column-card i {
- opacity: 0.72;
-}
-.path-row::before {
- content: "\f07b";
- font-family: "Font Awesome 6 Free";
- font-weight: 900;
- color: var(--bs-warning);
-}
-body.mobile-mode .mobile-card {
- display: block;
-}
-.mobile-card .mobile-actions button {
- min-width: 34px;
-}
-#toolSmart .form-label {
- font-size: 0.75rem;
- color: var(--bs-secondary-color);
- margin-bottom: 0.2rem;
-}
-#toolSmart .btn {
- padding: 0.25rem 0.55rem;
- border-radius: 0.5rem;
- white-space: nowrap;
-}
-#toolSmart .row .d-flex {
- align-items: end;
- justify-content: flex-start;
-}
-@media (max-width: 992px) {
- .profile-form-grid {
- grid-template-columns: 1fr;
- }
- .profile-form-grid .btn {
- width: 100%;
- }
-}
-
-.history-grid {
- display: grid;
- grid-template-columns: 1fr;
- gap: 1rem;
-}
-
-.history-card {
- min-width: 0;
- padding: 0.85rem;
- overflow: hidden;
- background: linear-gradient(180deg, rgba(var(--bs-secondary-bg-rgb), 0.58), rgba(var(--bs-secondary-bg-rgb), 0.28));
- border: 1px solid var(--bs-border-color);
- border-radius: 1rem;
- box-shadow: 0 0.5rem 1.75rem rgba(15, 23, 42, 0.08);
-}
-
-.history-title {
- margin-bottom: 0.55rem;
- color: var(--bs-body-color);
- font-size: 0.9rem;
- font-weight: 700;
- letter-spacing: 0.01em;
-}
-
-.traffic-chart {
- display: block;
- width: 100%;
- height: 420px;
- max-width: 100%;
- background: var(--bs-secondary-bg);
- border: 0;
- border-radius: 0.75rem;
-}
-
-
-.add-torrent-form {
- display: grid;
- gap: 0.85rem;
-}
-
-.add-start-switch {
- display: flex;
- align-items: center;
- min-height: 31px;
- margin-bottom: 0;
-}
-
-.traffic-chart-tooltip {
- position: fixed;
- z-index: 9000;
- min-width: 150px;
- padding: 0.45rem 0.6rem;
- color: var(--bs-body-color);
- background: var(--bs-body-bg);
- border: 1px solid var(--bs-border-color);
- border-radius: 0.5rem;
- box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.28);
- font-size: 0.78rem;
- pointer-events: none;
-}
-
-.traffic-tooltip-title {
- margin-bottom: 0.25rem;
- color: var(--bs-secondary-color);
- font-weight: 700;
-}
-
-.empty-mini {
- padding: 0.7rem 0.8rem;
- border: 1px dashed var(--bs-border-color);
- border-radius: 0.7rem;
- color: var(--bs-secondary-color);
- background: rgba(var(--bs-secondary-bg-rgb), 0.35);
-}
-.label-manager-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.5rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.65rem;
- padding: 0.4rem 0.5rem;
- margin-bottom: 0.4rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.35);
-}
-.tool-tab i {
- margin-right: 0.25rem;
- opacity: 0.82;
-}
-@media (max-width: 640px) {
- .history-card {
- padding: 0.5rem;
- }
- .traffic-chart {
- height: 320px;
- }
- .statusbar {
- font-size: 0.75rem;
- gap: 0.6rem;
- }
- .mobile-list {
- padding: 0.45rem;
- }
- .mobile-card {
- margin-bottom: 0.45rem;
- }
-}
-
-.torrent-progress {
- height: 16px;
- min-width: 92px;
- position: relative;
- margin: 0;
- overflow: hidden;
- background: rgba(var(--bs-secondary-bg-rgb), 0.8) !important;
-}
-.torrent-progress .progress-bar {
- min-width: 0 !important;
- position: relative;
- transition:
- width 0.25s ease,
- background-color 0.25s ease;
-}
-.torrent-progress > span {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 10px;
- font-weight: 700;
- line-height: 1;
- color: var(--bs-body-color);
- text-shadow: none;
- white-space: nowrap;
- pointer-events: none;
-}
-.torrent-progress .progress-bar + span {
- color: var(--bs-body-color);
-}
-@media (max-width: 700px) {
- body:not(.desktop-mode) .table-wrap {
- display: none !important;
- }
- body:not(.desktop-mode) #mobileList {
- display: block !important;
- min-height: 260px;
- height: 100%;
- overflow: auto;
- }
- body:not(.desktop-mode) .content {
- display: grid !important;
- grid-template-rows: auto auto minmax(0, 1fr) !important;
- min-height: 0;
- overflow: hidden;
- }
- body:not(.desktop-mode) .detail-resize-handle,
- body:not(.desktop-mode) .details {
- display: none !important;
- }
-}
-.pager-row {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: 0.5rem;
-}
-.peers-refresh {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- justify-content: flex-end;
- padding: 0.35rem 0.75rem;
- border-bottom: 1px solid var(--bs-border-color);
- background: rgba(var(--bs-secondary-bg-rgb), 0.35);
-}
-.peers-refresh select {
- width: auto;
- min-width: 96px;
-}
-
-@media (max-width: 900px) {
- body:not(.modal-open) .table-wrap {
- display: none !important;
- }
- body:not(.modal-open) #mobileList {
- display: block !important;
- height: 100% !important;
- min-height: 260px;
- overflow: auto;
- }
- body:not(.modal-open) .content {
- display: grid !important;
- grid-template-rows: auto auto minmax(0, 1fr) !important;
- min-height: 0;
- overflow: hidden;
- }
- body:not(.modal-open) .detail-resize-handle,
- body:not(.modal-open) .details {
- display: none !important;
- }
-}
-.torrent-paused td {
- opacity: 0.82;
-}
-.torrent-paused .name {
- font-style: italic;
-}
-
-@media (max-width: 900px) {
- .main-grid {
- display: grid !important;
- grid-template-columns: minmax(0, 1fr) !important;
- min-height: 0 !important;
- height: 100% !important;
- overflow: hidden !important;
- }
- .sidebar {
- display: none !important;
- }
- .content {
- display: grid !important;
- grid-template-rows: auto auto minmax(0, 1fr) !important;
- min-height: 0 !important;
- height: 100% !important;
- overflow: hidden !important;
- }
- .table-wrap {
- display: none !important;
- }
- #bulkBar {
- grid-row: 1;
- }
- #mobileList {
- display: block !important;
- grid-row: 3;
- height: 100% !important;
- min-height: 0 !important;
- overflow: auto !important;
- position: relative !important;
- z-index: 10 !important;
- background: var(--bs-body-bg) !important;
- padding: 0.55rem !important;
- }
- .details {
- display: none !important;
- }
- .toolbar-right {
- width: 100% !important;
- min-width: 0 !important;
- flex-wrap: nowrap !important;
- gap: 0.35rem !important;
- }
- .search {
- min-width: 0 !important;
- width: auto !important;
- flex: 1 1 0 !important;
- max-width: none !important;
- }
- .mobile-speed-stats {
- display: inline-flex;
- }
-}
-@media (max-width: 640px) {
- .mobile-speed-stats {
- align-items: flex-start;
- flex-direction: column;
- gap: 0.08rem;
- font-size: 0.66rem;
- line-height: 1.05;
- }
-}
-
-.files-toolbar {
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- gap: 0.75rem;
- justify-content: space-between;
- margin-bottom: 0.5rem;
-}
-.files-action-strip {
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
-}
-.files-action-section {
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- gap: 0.35rem;
-}
-.files-action-label {
- color: var(--muted);
- font-size: 0.72rem;
- font-weight: 700;
- letter-spacing: 0.04em;
- margin-right: 0.1rem;
- text-transform: uppercase;
-}
-.files-action-separator {
- align-self: stretch;
- background: var(--border);
- display: inline-block;
- min-height: 1.8rem;
- width: 1px;
-}
-.file-priority-table > :not(caption) > * > * {
- line-height: 1.15;
- padding: 0.22rem 0.4rem;
- vertical-align: middle;
-}
-.file-priority-table tbody tr {
- height: 30px;
-}
-.file-priority-table .path {
- max-width: 520px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.file-priority-table .file-priority {
- min-width: 110px;
-}
-.file-priority-table .file-progress {
- margin-bottom: 0;
- min-width: 110px;
- width: 120px;
-}
-.file-priority-table .form-select,
-.file-priority-table .btn-xs {
- min-height: 24px;
- padding-bottom: 0.1rem;
- padding-top: 0.1rem;
-}
-.file-priority-table .file-check,
-.file-priority-table #fileSelectAll {
- display: block;
- margin: 0 auto;
-}
-@media (max-width: 900px) {
- .files-toolbar {
- align-items: stretch;
- }
- .files-action-strip,
- .files-action-section {
- align-items: stretch;
- }
- .files-action-separator {
- min-height: auto;
- }
- .file-priority-table {
- font-size: 0.82rem;
- }
- .file-priority-table .path {
- max-width: 180px;
- }
-}
-
-.bulk-bar {
- height: 38px;
- display: flex;
- align-items: center;
- gap: 0.35rem;
- flex-wrap: nowrap;
- overflow-x: auto;
- overflow-y: hidden;
- padding: 0.35rem 0.55rem;
- border-bottom: 1px solid var(--bs-border-color);
- background: rgba(var(--bs-secondary-bg-rgb), 0.95);
- z-index: 4;
-}
-.bulk-bar.d-none {
- display: none !important;
-}
-.bulk-bar span {
- color: var(--bs-secondary-color);
- margin-right: 0.3rem;
- white-space: nowrap;
-}
-.bulk-bar .btn {
- white-space: nowrap;
- flex: 0 0 auto;
-}
-.move-options {
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- padding: 0.75rem;
- background: var(--bs-tertiary-bg);
-}
-#bulkBar {
- grid-row: 1;
- grid-column: 1;
- align-self: start;
-}
-#tableWrap,
-#mobileList {
- grid-row: 1;
- grid-column: 1;
- min-height: 0;
-}
-.bulk-bar:not(.d-none) + .table-wrap {
- padding-top: 38px;
-}
-@media (max-width: 900px) {
- .bulk-bar {
- gap: 0.3rem;
- }
-}
-
-.label-mini {
- font-size: 0.72rem;
- padding: 0.12rem 0.38rem;
- margin-right: 0.15rem;
-}
-.label-chip.active {
- border-color: var(--bs-primary);
- background: var(--bs-primary-bg-subtle);
- color: var(--bs-primary-text-emphasis);
-}
-.label-selected {
- border-color: var(--bs-primary);
- background: var(--bs-primary-bg-subtle);
- color: var(--bs-primary-text-emphasis);
-}
-
-.automation-shell {
- display: grid;
- gap: 0.75rem;
-}
-.automation-main-card {
- padding: 0.75rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- background: var(--bs-body-bg);
-}
-.automation-card-title {
- margin-bottom: 0.5rem;
- font-weight: 700;
-}
-.automation-rule-grid,
-.automation-builder-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(160px, 1fr));
- gap: 0.5rem;
- align-items: center;
-}
-.automation-enabled,
-.automation-negate {
- margin: 0;
- padding: 0.45rem 0.6rem 0.45rem 2.5rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.5rem;
-}
-.automation-path-input {
- grid-column: span 2;
-}
-.automation-chip-list {
- display: flex;
- flex-wrap: wrap;
- gap: 0.45rem;
-}
-.automation-chip {
- display: inline-flex;
- align-items: center;
- gap: 0.35rem;
- max-width: 100%;
- padding: 0.25rem 0.5rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 999px;
- background: var(--bs-tertiary-bg);
- font-size: 0.82rem;
-}
-.automation-actions,
-.automation-row-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.4rem;
- align-items: center;
-}
-.automation-row {
- display: flex;
- justify-content: space-between;
- gap: 0.75rem;
- align-items: center;
- padding: 0.55rem 0.65rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- margin-bottom: 0.45rem;
- background: var(--bs-body-bg);
-}
-.automation-row-main {
- min-width: 0;
-}
-.automation-rule-summary {
- overflow-wrap: anywhere;
-}
-.automation-action-pill {
- display: inline-flex;
- max-width: 100%;
- margin: 0.1rem;
- padding: 0.15rem 0.4rem;
- border-radius: 999px;
- background: var(--bs-secondary-bg);
- font-size: 0.78rem;
- overflow-wrap: anywhere;
- white-space: normal;
- word-break: break-word;
-}
-/* Note: Smart Queue stats are reusable because they are shown in App status. */
-.automation-smart-stats {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
- gap: 0.5rem;
- margin: 0.5rem 0 0.75rem;
-}
-.automation-smart-stat {
- min-width: 0;
- padding: 0.5rem 0.6rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.28);
-}
-.automation-smart-stat span,
-.automation-smart-stat small {
- display: block;
- color: var(--bs-secondary-color);
- font-size: 0.72rem;
- line-height: 1.2;
-}
-.automation-smart-stat b {
- display: block;
- overflow: hidden;
- font-size: 1rem;
- line-height: 1.3;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.automation-history-toolbar {
- display: flex;
- justify-content: flex-end;
- margin-bottom: 0.5rem;
-}
-/* Note: Automation history has fixed compact metadata columns and a flexible Actions column, so long JSON cannot overlap Time/Rule. */
-.automation-history-table {
- width: 100%;
- min-width: 760px;
- table-layout: fixed;
- white-space: normal;
-}
-.automation-history-table th,
-.automation-history-table td {
- min-width: 0;
- vertical-align: top;
-}
-.automation-history-table th:nth-child(1),
-.automation-history-table td:nth-child(1) {
- width: 9rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.automation-history-table th:nth-child(2),
-.automation-history-table td:nth-child(2) {
- width: 11rem;
- overflow: hidden;
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-.automation-history-table th:nth-child(3),
-.automation-history-table td:nth-child(3) {
- width: 12rem;
- overflow: hidden;
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-.automation-history-table th:nth-child(4),
-.automation-history-table td:nth-child(4) {
- width: auto;
- overflow: hidden;
- overflow-wrap: anywhere;
- word-break: break-word;
-}
-.automation-history-details {
- display: block;
- min-width: 0;
- max-width: 100%;
-}
-.automation-history-details summary {
- display: block;
- max-width: 100%;
- cursor: pointer;
- list-style-position: inside;
- overflow-wrap: anywhere;
- white-space: normal;
- word-break: break-word;
-}
-.automation-history-details pre,
-.automation-history-raw {
- max-width: 100%;
- max-height: 220px;
- margin: 0.35rem 0 0;
- padding: 0.5rem;
- overflow: auto;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.5rem;
- background: var(--bs-tertiary-bg);
- overflow-wrap: anywhere;
- white-space: pre-wrap;
- word-break: break-word;
-}
-@media (max-width: 900px) {
- .automation-rule-grid,
- .automation-builder-grid {
- grid-template-columns: 1fr;
- }
- .automation-path-input,
- .automation-history-details {
- grid-column: auto;
- max-width: 100%;
- }
- .automation-history-toolbar {
- justify-content: flex-start;
- }
-}
-.disk-status {
- display: inline-flex;
- align-items: center;
- gap: 0.35rem;
- min-width: 0;
- flex: 0 1 70%;
-}
-
-.disk-status canvas {
- width: 100%;
- max-width: none;
- min-width: 80px;
-}
-
-.disk-status.disk-warn b {
- color: var(--bs-warning) !important;
-}
-
-.system-chart {
- width: 96px;
- height: 24px;
- border-radius: 0.35rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.45);
-}
-.torrent-progress.is-complete > span {
- color: #fff;
- text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
-}
-.peer-progress {
- min-width: 86px;
- width: 96px;
-}
-.loading-center {
- justify-content: center;
- min-height: 80px;
-}
-.loading-cell {
- padding: 0 !important;
-}
-.mobile-list .loading-center {
- min-height: 160px;
-}
-
-.torrent-warning td {
- background: rgba(245, 158, 11, 0.075) !important;
-}
-.torrent-warning:hover td {
- background: rgba(245, 158, 11, 0.11) !important;
-}
-.torrent-warning.selected td {
- background: color-mix(
- in srgb,
- var(--bs-primary-bg-subtle) 82%,
- rgba(245, 158, 11, 0.16)
- ) !important;
-}
-.mobile-card.torrent-warning {
- background: rgba(245, 158, 11, 0.075);
-}
-.mobile-card.torrent-warning.selected {
- background: color-mix(
- in srgb,
- var(--bs-primary-bg-subtle) 82%,
- rgba(245, 158, 11, 0.16)
- );
-}
-.torrent-warning-icon {
- color: var(--bs-warning);
- margin-right: 0.2rem;
-}
-.mobile-filter-bar {
- display: none;
- grid-row: 2;
- grid-column: 1;
- align-self: start;
- position: relative;
- z-index: 12;
- padding: 0.45rem 0.55rem;
- border-bottom: 1px solid var(--bs-border-color);
- background: rgba(var(--bs-body-bg-rgb), 0.96);
-}
-.mobile-filter-actions,
-.mobile-filter-select-row {
- align-items: center;
- display: flex;
- gap: 0.35rem;
-}
-.mobile-filter-actions {
- flex-wrap: wrap;
- margin-bottom: 0.4rem;
-}
-.mobile-filter-actions span {
- color: var(--bs-secondary-color);
- font-size: 0.78rem;
- white-space: nowrap;
-}
-.mobile-filter-select-row label {
- color: var(--bs-secondary-color);
- font-size: 0.78rem;
- white-space: nowrap;
-}
-.mobile-filter-select-row select {
- min-width: 0;
- flex: 1 1 auto;
-}
-body.mobile-mode .mobile-filter-bar {
- display: block !important;
-}
-@media (max-width: 900px) {
- #mobileFilterBar {
- display: block !important;
- }
- .topbar .badge {
- width: 0.72rem;
- height: 0.72rem;
- min-width: 0.72rem;
- padding: 0 !important;
- border-radius: 999px;
- overflow: hidden;
- color: transparent !important;
- text-indent: -999px;
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.22);
- }
- .topbar .badge .spinner-border {
- display: none;
- }
-}
-
-.rt-config-grid {
- display: grid;
- gap: 0.6rem;
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
-}
-
-.rt-config-group {
- grid-column: 1 / -1;
- padding: 0.45rem 0.2rem 0.1rem;
- border-bottom: 1px solid var(--bs-border-color);
- color: var(--bs-primary-text-emphasis);
- font-weight: 800;
-}
-
-.rt-config-note {
- margin-bottom: 0.75rem;
-}
-
-.rt-config-toolbar {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 0.75rem;
- margin-bottom: 0.75rem;
-}
-
-.rt-config-row {
- display: grid;
- grid-template-columns: 1fr minmax(120px, 190px);
- align-items: center;
- gap: 0.6rem;
- padding: 0.6rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.7rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.35);
-}
-
-.rt-config-switch {
- justify-self: end;
- margin: 0;
-}
-
-.rt-config-switch .form-check-input {
- margin-top: 0;
-}
-
-.rt-config-switch .form-check-label {
- min-width: 2rem;
- color: var(--bs-secondary-color);
- font-size: 0.78rem;
- font-weight: 700;
-}
-
-.rt-config-row b {
- font-size: 0.88rem;
-}
-
-.rt-config-row small {
- display: block;
- overflow-wrap: anywhere;
- color: var(--bs-secondary-color);
- font-size: 0.72rem;
-}
-
-.rt-config-row.disabled {
- opacity: 0.58;
-}
-
-.rt-config-row.changed,
-.rt-config-row.changed-live {
- border-color: var(--bs-danger);
- box-shadow: 0 0 0 0.12rem rgba(220, 53, 69, 0.2);
-}
-
-.rt-config-value-note {
- margin-top: 0.15rem;
-}
-
-.rt-config-output {
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- font-size: 0.82rem;
-}
-
-.tracker-toolbar,
-.tracker-actions {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 0.45rem;
-}
-
-.tracker-toolbar {
- justify-content: space-between;
- margin-bottom: 0.55rem;
-}
-
-.tracker-add-input {
- min-width: 240px;
- max-width: 520px;
-}
-
-.tracker-message {
- max-width: 360px;
- white-space: normal;
- word-break: break-word;
-}
-
-.tracker-url-text {
- word-break: break-all;
-}
-
-.tool-note {
- color: var(--bs-secondary-color);
- font-size: 0.82rem;
-}
-
-.cleanup-grid,
-.diag-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
- gap: 0.6rem;
-}
-
-.cleanup-card,
-.diag-card {
- padding: 0.65rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.7rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.35);
-}
-
-.cleanup-card b,
-.diag-card b {
- display: block;
- margin-bottom: 0.2rem;
- color: var(--bs-secondary-color);
- font-size: 0.78rem;
-}
-
-.cleanup-card span,
-.diag-card span {
- font-weight: 700;
-}
-
-.cleanup-card small {
- display: block;
- margin-top: 0.2rem;
- overflow-wrap: anywhere;
- color: var(--bs-secondary-color);
-}
-
-.cleanup-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
-}
-
-.diag-error {
- border-color: rgba(var(--bs-danger-rgb), 0.45);
- background: rgba(var(--bs-danger-rgb), 0.08);
-}
-
-.port-status {
- display: inline-flex;
- align-items: center;
- gap: 0.3rem;
- padding: 0.12rem 0.4rem;
- border-radius: 0.45rem;
-}
-
-.port-ok {
- background: rgba(34, 197, 94, 0.14);
- color: var(--bs-success);
-}
-
-.port-bad {
- background: rgba(239, 68, 68, 0.14);
- color: var(--bs-danger);
-}
-
-.port-secondary {
- background: rgba(148, 163, 184, 0.12);
- color: var(--bs-secondary-color);
-}
-
-.limit-slider-panel {
- padding: 0.65rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.7rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.32);
-}
-
-.limit-slider-row + .limit-slider-row {
- margin-top: 0.65rem;
-}
-
-.limit-slider-row .form-label {
- display: flex;
- justify-content: space-between;
- gap: 0.75rem;
- margin-bottom: 0.25rem;
-}
-
-@media (max-width: 640px) {
- #mobileToggle {
- display: none !important;
- }
-
- .tracker-add-input {
- min-width: 160px;
- max-width: 230px;
- }
-
- .tracker-message {
- max-width: 220px;
- }
-}
-.text-compact {
- display: inline-block;
- max-width: 32rem;
- overflow: hidden;
- text-overflow: ellipsis;
- vertical-align: bottom;
- white-space: nowrap;
-}
-
-.torrent-operating td {
- background: rgba(13, 202, 240, 0.085) !important;
-}
-
-.torrent-operating:hover td {
- background: rgba(13, 202, 240, 0.13) !important;
-}
-
-.torrent-operating.selected td {
- background: color-mix(
- in srgb,
- var(--bs-primary-bg-subtle) 78%,
- rgba(13, 202, 240, 0.18)
- ) !important;
-}
-
-.mobile-card.torrent-operating {
- background: rgba(13, 202, 240, 0.085);
- border-color: rgba(13, 202, 240, 0.45);
-}
-
-.mobile-card.torrent-operating.selected {
- background: color-mix(
- in srgb,
- var(--bs-primary-bg-subtle) 78%,
- rgba(13, 202, 240, 0.18)
- );
-}
-
-.operation-status-badge {
- color: #062c33;
-}
-
-.mobile-progress {
- margin-top: 0.45rem;
-}
-
-.mobile-progress .torrent-progress {
- width: 100%;
- min-width: 0;
-}
-
-
-.empty-state {
- display: inline-flex;
- flex-direction: column;
- align-items: center;
- gap: 0.45rem;
- max-width: 34rem;
- white-space: normal;
-}
-.empty-state b {
- color: var(--bs-body-color);
- font-size: 0.95rem;
-}
-.empty-state span {
- color: var(--bs-secondary-color);
-}
-
-.footer-pref-hidden {
- display: none !important;
-}
-
-.footer-preferences {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
- gap: 0.5rem;
-}
-
-.footer-pref-card {
- display: flex;
- align-items: center;
- gap: 0.55rem;
- min-width: 0;
- margin: 0;
- padding: 0.6rem 0.7rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.45);
- cursor: pointer;
- user-select: none;
- transition:
- background 0.15s,
- border-color 0.15s;
-}
-
-.footer-pref-card:hover,
-.footer-pref-card.active {
- background: var(--bs-primary-bg-subtle);
-}
-
-.footer-pref-card:hover {
- border-color: var(--bs-primary);
-}
-
-.footer-pref-card.active {
- border-color: rgba(var(--bs-primary-rgb), 0.55);
-}
-
-.footer-pref-card .form-check-input {
- flex: 0 0 auto;
- margin: 0;
-}
-
-.footer-pref-card .form-check-label {
- min-width: 0;
- font-weight: 600;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-#statusClock,
-#statusSockets {
- white-space: nowrap;
-}
-
-
-.torrent-stats-toolbar {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- flex-wrap: wrap;
-}
-
-.torrent-stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
- gap: 0.75rem;
-}
-
-.torrent-stats-card {
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
- min-width: 0;
- padding: 0.75rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.85rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.45);
-}
-
-.torrent-stats-card b {
- color: var(--bs-secondary-color);
- font-size: 0.75rem;
- font-weight: 700;
- text-transform: uppercase;
-}
-
-.torrent-stats-card span {
- font-size: 1.05rem;
- font-weight: 700;
-}
-
-.torrent-stats-card small {
- color: var(--bs-secondary-color);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.peer-ip {
- display: inline-flex;
- align-items: center;
- gap: 0.35rem;
- white-space: nowrap;
-}
-
-.peer-ip-link {
- color: var(--bs-secondary-color);
- font-size: 0.75rem;
- text-decoration: none;
-}
-
-.peer-ip-link:hover {
- color: var(--bs-primary);
-}
-
-.auth-page {
- display: grid;
- min-height: 100vh;
- place-items: center;
- padding: 1rem;
- background: radial-gradient(
- circle at 50% 35%,
- rgba(var(--bs-secondary-bg-rgb), 0.98),
- var(--bs-body-bg) 68%
- );
- color: var(--bs-body-color);
-}
-
-.auth-card {
- width: min(92vw, 430px);
-}
-
-.auth-lock {
- display: inline-grid;
- width: 3rem;
- height: 3rem;
- margin: 1.35rem 0 1rem;
- place-items: center;
- border: 1px solid var(--bs-border-color);
- border-radius: 999px;
- background: rgba(var(--bs-tertiary-bg-rgb), 0.72);
- color: var(--bs-primary);
- font-size: 1.15rem;
-}
-
-.auth-alert {
- margin: 1rem 0 0;
- padding: 0.5rem 0.75rem;
- text-align: left;
-}
-
-.auth-form {
- margin-top: 1.2rem;
- text-align: left;
-}
-
-.auth-form .form-label {
- margin-bottom: 0.35rem;
- font-size: 0.82rem;
- font-weight: 700;
- color: var(--bs-secondary-color);
-}
-
-.auth-form .form-control {
- margin-bottom: 0.85rem;
-}
-
-.auth-form .btn {
- margin-top: 0.35rem;
-}
-
-.user-form-grid {
- display: grid;
- grid-template-columns: minmax(150px, 1fr) minmax(160px, 1fr) 120px 150px 110px auto auto;
- gap: 0.55rem;
- align-items: center;
-}
-
-.smart-panel {
- container-type: inline-size;
-}
-
-.smart-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 1rem;
- padding-bottom: 0.75rem;
- border-bottom: 1px solid var(--bs-border-color);
-}
-
-.smart-header-actions {
- display: flex;
- align-items: center;
- gap: 0.45rem;
- flex-wrap: wrap;
- justify-content: flex-end;
- flex: 0 0 auto;
-}
-
-.smart-settings-list {
- display: grid;
- gap: 0.65rem;
- margin-top: 0.85rem;
-}
-
-.smart-setting-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
- min-height: 52px;
- padding: 0.6rem 0.7rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.65rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.28);
-}
-
-/* Keep Bootstrap switches aligned inside compact settings rows. */
-.inline-switch,
-.smart-toggle-row .form-check {
- display: inline-flex;
- align-items: center;
- justify-content: flex-end;
- gap: 0.45rem;
- flex: 0 0 auto;
- min-height: 0;
- margin: 0;
- padding-left: 0;
-}
-
-.inline-switch .form-check-input,
-.smart-toggle-row .form-check-input {
- flex: 0 0 auto;
- margin-top: 0;
- margin-left: 0;
-}
-
-.inline-switch .form-check-label {
- line-height: 1.2;
- white-space: nowrap;
-}
-
-.smart-setting-row > div:first-child {
- min-width: 0;
-}
-
-.smart-setting-row b,
-.smart-setting-row small {
- display: block;
-}
-
-.smart-setting-row .form-check-label,
-.smart-input-field span {
- font-weight: 700;
-}
-
-.smart-input-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(120px, 1fr));
- gap: 0.65rem;
-}
-
-.smart-input-field {
- display: grid;
- gap: 0.35rem;
- min-width: 0;
- padding: 0.6rem 0.7rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.65rem;
- background: rgba(var(--bs-body-bg-rgb), 0.48);
-}
-
-.smart-input-field small {
- color: var(--bs-secondary-color);
- line-height: 1.2;
-}
-
-.smart-input-field .form-control {
- width: 100%;
-}
-
-.smart-actions {
- display: flex;
- align-items: center;
- gap: 0.45rem;
- flex-wrap: wrap;
- padding: 0.7rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.65rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.24);
-}
-
-@media (max-width: 992px) {
- .user-form-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .smart-input-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 576px) {
- .user-form-grid,
- .smart-input-grid {
- grid-template-columns: 1fr;
- }
-
- .smart-header,
- .smart-setting-row {
- align-items: stretch;
- flex-direction: column;
- }
-
- .smart-header-actions {
- justify-content: stretch;
- }
-
- .smart-header-actions .btn {
- flex: 1 1 auto;
- }
-
- .smart-toggle-row .form-check {
- justify-content: flex-start;
- }
-}
-
-
-/* Note: About and error-page styles are grouped without duplicating existing classes. */
-.about-modal-content {
- overflow: hidden;
-}
-
-.about-nav-btn {
- opacity: 0.82;
-}
-
-.about-nav-btn:hover,
-.about-nav-btn:focus-visible {
- opacity: 1;
-}
-
-.about-hero {
- display: flex;
- align-items: center;
- gap: 0.85rem;
- margin-bottom: 1rem;
- padding: 0.9rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.85rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.38);
-}
-
-.about-logo {
- display: inline-grid;
- width: 2.8rem;
- height: 2.8rem;
- flex: 0 0 auto;
- place-items: center;
- border-radius: 0.8rem;
- background: var(--bs-primary-bg-subtle);
- color: var(--bs-primary-text-emphasis);
- font-size: 1.25rem;
-}
-
-.about-hero h6,
-.about-hero p {
- margin: 0;
-}
-
-.about-hero h6 {
- font-weight: 800;
-}
-
-.about-hero p {
- color: var(--bs-secondary-color);
-}
-
-
-.about-summary-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
- gap: 0.6rem;
-}
-
-.about-summary-grid div {
- padding: 0.7rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.28);
-}
-
-.about-summary-grid b,
-.about-summary-grid span {
- display: block;
-}
-
-.about-summary-grid b {
- margin-bottom: 0.2rem;
-}
-
-.about-summary-grid span {
- color: var(--bs-secondary-color);
- font-size: 0.82rem;
-}
-
-.about-list {
- display: grid;
- gap: 0.55rem;
- margin: 0;
-}
-
-.about-list div {
- display: grid;
- grid-template-columns: 7rem minmax(0, 1fr);
- gap: 0.75rem;
- padding: 0.55rem 0;
- border-bottom: 1px solid var(--bs-border-color);
-}
-
-.about-list div:last-child {
- border-bottom: 0;
-}
-
-.about-list dt {
- color: var(--bs-secondary-color);
- font-weight: 700;
-}
-
-.about-list dd {
- margin: 0;
-}
-
-.error-page {
- display: grid;
- min-height: 100vh;
- place-items: center;
- padding: 1rem;
- background: radial-gradient(
- circle at 50% 35%,
- rgba(var(--bs-secondary-bg-rgb), 0.98),
- var(--bs-body-bg) 68%
- );
- color: var(--bs-body-color);
-}
-
-.error-card {
- width: min(92vw, 460px);
- padding: 2rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 18px;
- background: rgba(var(--bs-secondary-bg-rgb), 0.9);
- box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48);
- text-align: center;
-}
-
-.error-brand {
- font-size: 1.2rem;
- font-weight: 800;
-}
-
-.error-icon {
- display: inline-grid;
- width: 4rem;
- height: 4rem;
- margin: 1.4rem 0 1rem;
- place-items: center;
- border: 1px solid var(--bs-border-color);
- border-radius: 1rem;
- background: var(--bs-primary-bg-subtle);
- color: var(--bs-primary-text-emphasis);
- font-size: 1.55rem;
-}
-
-.error-code {
- margin: 0;
- color: var(--bs-secondary-color);
- font-size: 0.78rem;
- font-weight: 800;
- letter-spacing: 0.18em;
- text-transform: uppercase;
-}
-
-.error-card h1 {
- margin: 0.25rem 0 0.55rem;
- font-size: 1.45rem;
- font-weight: 800;
-}
-
-.error-card p:not(.error-code) {
- margin: 0;
- color: var(--bs-secondary-color);
-}
-
-.error-actions {
- display: flex;
- justify-content: center;
- gap: 0.55rem;
- flex-wrap: wrap;
- margin-top: 1.35rem;
-}
-
-@media (max-width: 576px) {
- .about-list div {
- grid-template-columns: 1fr;
- gap: 0.15rem;
- }
-
- .error-actions .btn {
- width: 100%;
- }
-}
-
-.date-readable {
- display: inline-block;
- min-width: 9.5rem;
- white-space: nowrap;
-}
-
-
-.cooldown-live {
- display: inline-flex;
- margin-left: 0.35rem;
- padding: 0.05rem 0.35rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 999px;
- color: var(--bs-secondary-color);
- font-size: 0.72rem;
- font-weight: 700;
-}
-
-.disk-monitor-grid {
- display: grid;
- grid-template-columns: minmax(220px, 1.3fr) minmax(170px, 0.8fr) minmax(170px, 0.8fr);
- gap: 0.6rem;
- align-items: start;
-}
-
-.disk-monitor-grid .chips {
- grid-column: 1 / -1;
-}
-
-.disk-path-chip {
- gap: 0.25rem;
- max-width: 100%;
- overflow-wrap: anywhere;
-}
-
-.disk-path-remove {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 1.75rem;
- height: 1.75rem;
- padding: 0;
- border: 0;
- border-radius: 0;
- background: transparent;
- box-shadow: none;
- color: var(--bs-danger);
- line-height: 1;
-}
-
-.disk-path-remove:hover,
-.disk-path-remove:focus-visible {
- border: 0;
- background: transparent;
- box-shadow: none;
- color: var(--bs-danger-text-emphasis);
- outline: 0;
-}
-
-.jobs-table {
- min-width: 1080px;
- white-space: normal;
-}
-
-.jobs-table th:nth-child(8),
-.jobs-table td:nth-child(8),
-.jobs-table th:nth-child(9),
-.jobs-table td:nth-child(9) {
- min-width: 10.5rem;
-}
-
-.jobs-table td:nth-child(6),
-.jobs-table td:nth-child(10) {
- max-width: 18rem;
- overflow-wrap: anywhere;
- white-space: normal;
-}
-
-@media (max-width: 768px) {
- .disk-monitor-grid {
- grid-template-columns: 1fr;
- }
-
- .disk-monitor-grid .chips {
- grid-column: auto;
- }
-}
-
-/* Note: Smart Queue cooldown, refill and Disk monitor controls are grouped here to keep the new UX styles isolated. */
-.smart-cooldown-card {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
- padding: 0.75rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- background: rgba(var(--bs-primary-rgb), 0.08);
-}
-
-.smart-cooldown-label,
-.smart-cooldown-field span,
-.disk-monitor-card-title {
- display: block;
- font-weight: 700;
-}
-
-.smart-cooldown-live {
- margin: 0.25rem 0 0;
- color: var(--bs-primary-text-emphasis);
- background: rgba(var(--bs-primary-rgb), 0.12);
- border-color: rgba(var(--bs-primary-rgb), 0.28);
- font-size: 0.9rem;
-}
-
-.smart-cooldown-card small,
-.smart-cooldown-field small,
-.disk-monitor-switch small,
-.disk-path-row small {
- display: block;
- color: var(--bs-secondary-color);
- line-height: 1.25;
-}
-
-.smart-cooldown-field {
- display: grid;
- gap: 0.3rem;
- width: min(180px, 100%);
-}
-
-
-.smart-refill-card {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
- padding: 0.75rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.28);
-}
-
-.smart-refill-title,
-.smart-refill-field span {
- display: block;
- font-weight: 700;
-}
-
-.smart-refill-card small {
- display: block;
- color: var(--bs-secondary-color);
- line-height: 1.25;
-}
-
-.smart-refill-controls {
- display: grid;
- grid-template-columns: minmax(130px, 1fr) minmax(90px, 0.7fr);
- gap: 0.55rem;
- width: min(330px, 100%);
-}
-
-.smart-refill-field {
- display: grid;
- gap: 0.3rem;
-}
-.disk-monitor-shell {
- display: grid;
- grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr);
- gap: 0.75rem;
-}
-
-.disk-monitor-mode-card,
-.disk-monitor-path-card {
- display: grid;
- gap: 0.55rem;
- padding: 0.75rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- background: rgba(var(--bs-body-bg-rgb), 0.45);
-}
-
-.disk-monitor-switch {
- display: grid;
- grid-template-columns: auto 1fr;
- column-gap: 0.6rem;
- row-gap: 0.1rem;
- align-items: start;
- min-height: auto;
- margin: 0;
- padding: 0.55rem 0.6rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.25);
-}
-
-.disk-monitor-switch .form-check-input {
- grid-row: span 2;
- margin-left: 0;
-}
-
-.disk-monitor-switch .form-check-label {
- font-weight: 700;
-}
-
-.disk-monitor-path-list {
- display: grid;
- gap: 0.45rem;
-}
-
-.disk-path-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.75rem;
- padding: 0.55rem 0.65rem;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.22);
-}
-
-.disk-path-row b {
- display: block;
- overflow-wrap: anywhere;
-}
-
-.disk-path-actions {
- display: flex;
- gap: 0.35rem;
- flex: 0 0 auto;
-}
-
-@media (max-width: 768px) {
- .smart-cooldown-card,
- .smart-refill-card,
- .disk-path-row {
- align-items: stretch;
- flex-direction: column;
- }
-
- .disk-monitor-shell {
- grid-template-columns: 1fr;
- }
-
- .disk-path-actions {
- justify-content: flex-start;
- }
-}
-
-/* Note: RSS and ratio management forms use shared grid rules to avoid one-off duplicated layout classes. */
-.ratio-rule-grid,
-.rss-form-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
- gap: 0.5rem;
- align-items: center;
-}
-
-.ratio-rule-grid .form-check,
-.rss-form-grid .form-check {
- margin-bottom: 0;
-}
-
-@media (max-width: 768px) {
- .ratio-rule-grid,
- .rss-form-grid {
- grid-template-columns: 1fr;
- }
-}
-
-
-.dragging-torrent-files .table-wrap,
-.dragging-torrent-files .mobile-list {
- outline: 2px dashed var(--bs-primary);
- outline-offset: -0.4rem;
-}
-
-.dragging-torrent-files .table-wrap::after {
- align-items: center;
- background: color-mix(in srgb, var(--bs-body-bg) 82%, transparent);
- border: 1px dashed var(--bs-primary);
- border-radius: 0.75rem;
- color: var(--bs-primary);
- content: 'Drop .torrent files to add them';
- display: flex;
- font-weight: 700;
- inset: 0.75rem;
- justify-content: center;
- pointer-events: none;
- position: absolute;
- z-index: 5;
-}
-
-.torrent-preview {
- display: grid;
- gap: .75rem;
-}
-
-.torrent-preview-title {
- color: var(--bs-secondary-color);
- font-size: .82rem;
- font-weight: 700;
- text-transform: uppercase;
-}
-
-.torrent-preview-card {
- border: 1px solid var(--bs-border-color);
- border-radius: .75rem;
- padding: .75rem;
-}
-
-.torrent-preview-card.is-duplicate {
- border-color: var(--bs-danger);
-}
-
-.torrent-preview-head,
-.preview-actions,
-.file-tree-actions {
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- gap: .5rem;
-}
-
-.preview-file-table {
- margin-bottom: 0;
-}
-
-.preview-file-table td:first-child {
- width: 2.25rem;
-}
-
-.file-tree-panel {
- border: 1px solid var(--bs-border-color);
- border-radius: .75rem;
- margin: .75rem 0;
- max-height: 18rem;
- overflow: auto;
- padding: .75rem;
-}
-
-.file-tree-root,
-.file-tree-root ul {
- list-style: none;
- margin: 0;
- padding-left: 1rem;
-}
-
-.file-tree-root > li {
- padding-left: 0;
-}
-
-.file-tree-file,
-.file-tree-root summary {
- align-items: center;
- display: flex;
- gap: .4rem;
- min-height: 1.75rem;
-}
-
-.file-tree-root small {
- color: var(--bs-secondary-color);
-}
-
-/* Planner / adaptive poller */
-.tool-tab[data-tool="planner"],
-.tool-tab[data-tool="poller"] {
- white-space: nowrap;
-}
-
-.planner-panel .smart-header,
-.poller-panel .smart-header {
- margin-bottom: 0.85rem;
-}
-
-.planner-layout,
-.planner-toggle-stack {
- display: grid;
- gap: 0.85rem;
-}
-
-.planner-card {
- background: rgba(var(--bs-secondary-bg-rgb), 0.36);
- border: 1px solid var(--bs-border-color);
- border-radius: 0.85rem;
- min-width: 0;
- padding: 0.85rem;
-}
-
-.planner-card-title {
- align-items: center;
- display: flex;
- font-weight: 700;
- gap: 0.45rem;
- margin-bottom: 0.7rem;
-}
-
-.planner-card-time,
-.planner-card-protection {
- display: grid;
- gap: 0.75rem;
-}
-
-.planner-card-time .planner-card-title,
-.planner-card-protection .planner-card-title {
- margin-bottom: 0;
-}
-
-.planner-card-time .planner-time-grid,
-.planner-card-protection .planner-protection-grid {
- margin-top: 0;
-}
-
-.planner-toggle-stack-compact {
- grid-template-columns: repeat(2, minmax(220px, 1fr));
-}
-
-.planner-protection-toggles {
- grid-template-columns: repeat(2, minmax(240px, 1fr));
-}
-
-.planner-card .smart-setting-row {
- align-items: flex-start;
- gap: 0.75rem;
- min-height: auto;
-}
-
-.planner-card .smart-setting-row > div:first-child {
- flex: 1 1 auto;
-}
-
-.planner-card .smart-setting-row .inline-switch,
-.planner-card .smart-setting-row .form-check {
- align-self: flex-start;
- margin-top: 0.1rem;
-}
-
-.planner-time-grid,
-.planner-profile-grid,
-.poller-input-grid {
- grid-template-columns: repeat(4, minmax(130px, 1fr));
-}
-
-.planner-protection-grid {
- grid-template-columns: repeat(5, minmax(130px, 1fr));
-}
-
-.planner-speed-grid {
- grid-template-columns: repeat(2, minmax(260px, 1fr));
-}
-
-.planner-speed-card {
- gap: 0.45rem;
-}
-
-.planner-limit-summary {
- color: var(--bs-secondary-color);
- font-size: 0.82rem;
-}
-
-.planner-presets,
-.planner-hour-tools,
-.tool-action-row {
- display: flex;
- flex-wrap: wrap;
- gap: 0.4rem;
-}
-
-.planner-speed-sliders {
- align-items: center;
- display: grid;
- gap: 0.45rem 0.65rem;
- grid-template-columns: minmax(160px, 1fr) 100px;
-}
-
-.planner-speed-sliders label {
- color: var(--bs-secondary-color);
- margin: 0;
-}
-
-.planner-byte-input {
- font-family: var(--bs-font-monospace);
-}
-
-.tool-action-row {
- align-items: center;
- margin-top: 0.85rem;
-}
-
-.planner-hour-tools {
- margin: 0.65rem 0;
-}
-
-.planner-hour-grid {
- display: grid;
- gap: 0.35rem;
- grid-template-columns: repeat(2, minmax(280px, 1fr));
- max-height: 420px;
- overflow: auto;
- padding-right: 0.25rem;
-}
-
-.planner-hour-row {
- align-items: center;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.6rem;
- display: grid;
- gap: 0.4rem;
- grid-template-columns: 6.2rem 1fr 1fr minmax(8rem, auto);
- padding: 0.35rem;
-}
-
-.planner-hour-row > span {
- font-weight: 700;
-}
-
-.planner-hour-row small {
- color: var(--bs-secondary-color);
-}
-
-.planner-hour-row small {
- white-space: nowrap;
-}
-
-.planner-card-result small,
-#pollerRuntime {
- display: block;
-}
-
-.planner-preview-row small,
-.planner-history-row small,
-#pollerRuntime {
- line-height: 1.45;
-}
-
-.planner-history-item {
- background: rgba(var(--bs-secondary-bg-rgb), 0.45);
- border: 1px solid var(--bs-border-color);
- border-radius: 999px;
- display: inline-block;
- margin: 0.15rem 0.35rem 0.15rem 0;
- padding: 0.15rem 0.4rem;
-}
-
-#pollerRuntime {
- margin-top: 0.25rem;
-}
-
-.status-planner {
- align-items: center;
- background: transparent;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.35rem;
- color: inherit;
- display: inline-flex;
- gap: 0.35rem;
- line-height: 1.2;
- padding: 0.1rem 0.45rem;
-}
-
-.status-planner:hover {
- background: rgba(var(--bs-secondary-bg-rgb), 0.5);
-}
-
-.tracker-scope-badge {
- border: 1px solid var(--bs-border-color);
- border-radius: 999px;
- color: var(--bs-primary);
- font-size: 0.65rem;
- margin-left: 0.25rem;
- padding: 0 0.3rem;
-}
-
-.tracker-filter-all {
- border-style: dashed;
-}
-
-@media (max-width: 1100px) {
- .planner-hour-grid,
- .planner-protection-toggles {
- grid-template-columns: 1fr;
- }
-
- .planner-protection-grid {
- grid-template-columns: repeat(3, minmax(130px, 1fr));
- }
-}
-
-@media (max-width: 900px) {
- .planner-time-grid,
- .planner-profile-grid,
- .planner-protection-grid,
- .planner-speed-grid,
- .poller-input-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 720px) {
- .planner-hour-row,
- .planner-speed-sliders,
- .planner-time-grid,
- .planner-profile-grid,
- .planner-protection-grid,
- .planner-speed-grid,
- .planner-toggle-stack-compact,
- .poller-input-grid {
- grid-template-columns: 1fr;
- }
-
- .planner-card .smart-setting-row {
- flex-direction: column;
- }
-
- .planner-hour-row small {
- white-space: normal;
- }
-
- .tool-action-row .btn {
- flex: 1 1 auto;
- }
-}
-
-
-/* Phase 5 dashboard, smart views and notifications */
-.health-dashboard-grid,
-.smart-view-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
- gap: .75rem;
-}
-.health-card,
-.smart-view-card,
-.notification-item {
- border: 1px solid var(--bs-border-color);
- border-radius: .75rem;
- background: var(--bs-body-bg);
- box-shadow: 0 .25rem .8rem rgba(15, 23, 42, .04);
-}
-.health-card {
- padding: .85rem;
- min-width: 0;
-}
-.health-card-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: .75rem;
- margin-bottom: .25rem;
-}
-.health-card > small,
-.smart-view-card small,
-.notification-item small {
- color: var(--bs-secondary-color);
-}
-.health-list {
- display: grid;
- gap: .4rem;
- margin-top: .65rem;
-}
-.health-row {
- display: grid;
- gap: .15rem;
- width: 100%;
- padding: .45rem .55rem;
- border: 1px solid var(--bs-border-color);
- border-radius: .55rem;
- background: var(--bs-tertiary-bg);
- color: inherit;
- text-align: left;
-}
-.health-row span,
-.health-row small {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.smart-view-card {
- display: grid;
- gap: .35rem;
- padding: .9rem;
- text-align: left;
- color: inherit;
-}
-.smart-view-card.active,
-.smart-view-card:hover {
- border-color: var(--bs-primary);
-}
-.smart-view-card span {
- font-size: .8rem;
- color: var(--bs-primary);
-}
-.notification-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: .75rem;
- margin-bottom: .75rem;
-}
-.notification-list {
- display: grid;
- gap: .55rem;
-}
-.notification-item {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: .65rem;
- padding: .7rem .8rem;
-}
-.notification-item > i {
- margin-top: .15rem;
-}
-.notification-item > div {
- display: grid;
- gap: .15rem;
- min-width: 0;
-}
-.notification-item span {
- overflow-wrap: anywhere;
-}
-.notification-error > i,
-.notification-warning > i {
- color: var(--bs-warning);
-}
-.notification-planner > i,
-.notification-queue > i {
- color: var(--bs-primary);
-}
-
-/* Diagnostics layout */
-.diagnostics-section {
- display: grid;
- gap: .75rem;
- margin-bottom: 1rem;
-}
-.diagnostics-section:last-child {
- margin-bottom: 0;
-}
-
-/* Columns tab panes keep the original column card layout for both views. */
-.column-manager-tabs,
-.column-manager-pane {
- grid-column: 1 / -1;
-}
-.column-manager-tabs {
- margin-bottom: .75rem;
-}
-.column-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
- gap: 0.55rem;
-}
-.mobile-sort-row .btn {
- pointer-events: auto;
-}
-.mobile-progress:empty {
- display: none;
-}
-
-.profile-status-badge{font-size:.7rem;text-transform:uppercase;letter-spacing:.02em;}
-.profile-diagnostics-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.5rem;}
-.profile-diagnostics-card{border:1px solid var(--bs-border-color);border-radius:.5rem;padding:.5rem;background:var(--bs-body-bg);}
-.profile-diagnostics-card small{display:block;color:var(--bs-secondary-color);}
-
-.labels-manager { display: grid; gap: 0.5rem; }
-.profile-status-badge.badge { min-height: 1.25rem; line-height: 1; display: inline-flex; align-items: center; padding: .25em .5em; }
-
-/* UI hygiene: keep long status/footer content inside the app instead of widening the browser viewport. */
-html,
-body,
-.app-shell,
-.topbar,
-.main-grid,
-.content,
-.statusbar {
- max-width: 100%;
- min-width: 0;
-}
-
-.statusbar {
- overflow-x: auto;
- overflow-y: hidden;
- scrollbar-width: thin;
- overscroll-behavior-x: contain;
-}
-
-.statusbar > * {
- flex: 0 0 auto;
-}
-
-/* Compact rTorrent profile badges so online/slow/degraded match archive-style pills. */
-.profile-status-badge.badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: auto;
- min-width: 0;
- min-height: 1.2rem;
- max-width: max-content;
- padding: 0.18rem 0.45rem;
- font-size: 0.68rem;
- line-height: 1;
- letter-spacing: 0.015em;
- text-transform: uppercase;
- white-space: nowrap;
- vertical-align: middle;
-}
-
-.profile-row {
- grid-template-columns: minmax(0, 1fr) max-content;
-}
-
-.profile-actions {
- justify-content: flex-end;
-}
-
-.preferences-browser-layout {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
- gap: 0.75rem;
-}
-
-.preference-block {
- height: 100%;
-}
-
-.management-card {
- border: 1px solid var(--bs-border-color);
- border-radius: 0.8rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.42);
- padding: 0.85rem;
-}
-
-.management-card-title {
- display: flex;
- align-items: center;
- gap: 0.45rem;
- margin-bottom: 0.7rem;
- color: var(--bs-body-color);
- font-weight: 700;
-}
-
-.management-form-grid {
- align-items: end;
-}
-
-.management-form-grid .form-field,
-.management-switch {
- min-width: 0;
- margin: 0;
-}
-
-.management-form-grid .form-field > span:first-child {
- display: block;
- margin-bottom: 0.25rem;
- color: var(--bs-secondary-color);
- font-size: 0.75rem;
- font-weight: 700;
- text-transform: uppercase;
-}
-
-.management-form-grid .form-field-wide {
- grid-column: span 2;
-}
-
-.management-switch {
- display: flex;
- align-items: center;
- min-height: 2.35rem;
- gap: 0.45rem;
-}
-
-.management-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 0.45rem;
- margin-top: 0.75rem;
-}
-
-.tool-split-section .table,
-.management-card .table {
- margin-bottom: 0;
-}
-
-#smartPane-logs,
-#automationPane-logs {
- padding-top: 0.25rem;
-}
-
-@media (max-width: 768px) {
- .management-form-grid .form-field-wide {
- grid-column: auto;
- }
-
- .management-actions {
- align-items: stretch;
- flex-direction: column;
- }
-}
-
-/* Keep rTorrent diagnostics badges visually aligned with the smaller active/archive pills. */
-.profile-row .profile-status-badge.badge {
- min-height: 1rem;
- padding: 0.1rem 0.32rem;
- font-size: 0.58rem;
- line-height: 1;
- letter-spacing: 0.01em;
- border-radius: 999px;
-}
-
-/* Flat nested sections inside already framed preference panels. */
-.disk-monitor-shell-flat .disk-monitor-mode-card,
-.disk-monitor-shell-flat .disk-monitor-path-card {
- border: 0;
- border-radius: 0;
- background: transparent;
- padding: 0;
-}
-
-
-.create-torrent-form {
- display: grid;
- gap: 0.85rem;
-}
-
-.create-source-row,
-.create-fieldset {
- padding: 0.85rem;
- background: rgba(var(--bs-secondary-bg-rgb), 0.35);
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
-}
-
-.create-fieldset {
- min-width: 0;
-}
-
-.create-fieldset legend {
- float: none;
- width: auto;
- margin: 0 0 0.65rem;
- padding: 0;
- color: var(--bs-secondary-color);
- font-size: 0.8rem;
- font-weight: 700;
-}
-
-.create-options {
- display: flex;
- flex-wrap: wrap;
- gap: 1rem;
- align-items: center;
-}
-
-.function-note {
- padding: 0.55rem 0.7rem;
- color: var(--bs-secondary-color);
- background: rgba(var(--bs-secondary-bg-rgb), 0.42);
- border: 1px solid var(--bs-border-color);
- border-radius: 0.65rem;
- font-size: 0.82rem;
-}
-
-.function-note b {
- color: var(--bs-body-color);
-}
-
-
-@media (max-width: 768px) {
- .create-options,
- .add-start-switch {
- align-items: flex-start;
- }
-}
-
-
-/* Add/create torrent modal refinements. These classes extend the existing modal and form styles without replacing shared CSS. */
-.add-create-modal-body {
- padding-top: 1rem;
-}
-
-.add-create-tab-content {
- margin-top: 0.25rem;
-}
-
-.add-torrent-layout {
- display: grid;
- gap: 0.85rem;
-}
-
-.add-torrent-panel {
- background: color-mix(in srgb, var(--bs-body-bg) 92%, var(--bs-tertiary-bg));
- border: 1px solid color-mix(in srgb, var(--bs-border-color) 72%, transparent);
- border-radius: 0.85rem;
- box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent);
- padding: 0.9rem;
-}
-
-.add-torrent-panel-heading,
-.add-target-grid,
-.create-options-panel {
- align-items: center;
- display: grid;
- gap: 0.75rem;
-}
-
-.add-torrent-panel-heading {
- grid-template-columns: minmax(0, 1fr) auto;
- margin-bottom: 0.75rem;
-}
-
-.add-magnet-input {
- min-height: 8.5rem;
- resize: vertical;
-}
-
-.add-file-picker {
- overflow: hidden;
- position: relative;
- white-space: nowrap;
-}
-
-.add-file-picker input {
- height: 1px;
- opacity: 0;
- position: absolute;
- right: 0;
- top: 0;
- width: 1px;
-}
-
-.add-file-summary {
- align-items: center;
- background: var(--bs-tertiary-bg);
- border: 1px dashed var(--bs-border-color);
- border-radius: 0.7rem;
- color: var(--bs-secondary-color);
- display: flex;
- min-height: 2.6rem;
- padding: 0.6rem 0.75rem;
-}
-
-.add-file-preview:not(:empty) {
- margin-top: 0.75rem;
-}
-
-.add-target-grid {
- grid-template-columns: minmax(14rem, 1fr) minmax(10rem, 16rem) auto;
-}
-
-.add-start-card {
- align-items: center;
- display: flex;
- gap: 0.55rem;
- justify-content: flex-start;
- margin: 0;
- min-height: 2.4rem;
- padding: 0;
- white-space: nowrap;
-}
-
-.add-start-card .form-check-input {
- flex: 0 0 auto;
- margin: 0;
-}
-
-.create-properties-grid,
-.create-meta-grid {
- display: grid;
- gap: 0.75rem;
- grid-template-columns: minmax(0, 1fr) minmax(13rem, 18rem);
-}
-
-.create-side-fields {
- display: grid;
- gap: 0.75rem;
-}
-
-.create-options-panel {
- grid-template-columns: repeat(auto-fit, minmax(13rem, max-content));
- justify-content: start;
-}
-
-@media (max-width: 992px) {
- .add-target-grid,
- .create-properties-grid,
- .create-meta-grid {
- grid-template-columns: 1fr;
- }
-
- .add-start-card {
- justify-content: flex-start;
- }
-}
-
-@media (max-width: 576px) {
- .add-torrent-panel-heading {
- grid-template-columns: 1fr;
- }
-}
-
-/* API tokens and path picker improvements */
-.api-token-row {
- align-items: center;
- border: 1px solid var(--bs-border-color);
- border-radius: 0.75rem;
- display: flex;
- gap: 0.75rem;
- justify-content: space-between;
- padding: 0.75rem;
-}
-
-.api-token-row + .api-token-row {
- margin-top: 0.5rem;
-}
-
-.api-token-row small {
- color: var(--bs-secondary-color);
- display: block;
- margin-top: 0.15rem;
-}
-
-.path-info-strip {
- align-items: center;
- background: var(--bs-tertiary-bg);
- border-bottom: 1px solid var(--bs-border-color);
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem 0.85rem;
- padding: 0.65rem 0.75rem;
-}
-
-.path-info-strip span {
- color: var(--bs-secondary-color);
- font-size: 0.82rem;
-}
-
-@media (max-width: 576px) {
- .api-token-row {
- align-items: stretch;
- flex-direction: column;
- }
-}
-
-
-/* Stacked modal and auth token refinements. */
-#pathModal {
- z-index: 1080;
-}
-
-#pathModal + .modal-backdrop,
-.modal-backdrop.path-picker-backdrop {
- z-index: 1075;
-}
-
-.api-token-inline {
- background: var(--bs-tertiary-bg);
- border: 1px solid var(--bs-border-color);
- border-radius: 0.85rem;
- padding: 0.85rem;
-}
-
-.api-token-inline .input-group {
- margin-top: 0.45rem;
-}
-
-.api-token-inline small {
- color: var(--bs-secondary-color);
- display: block;
-}
-
-
-/* Note: Tool forms and generated action columns must stay inside modal width on narrow screens. */
-#toolsModal .modal-body {
- min-width: 0;
- overflow-x: hidden;
-}
-
-#toolsModal .nav-pills {
- flex-wrap: nowrap;
- max-width: 100%;
- overflow-x: auto;
- padding-bottom: 0.2rem;
- scrollbar-width: thin;
- -webkit-overflow-scrolling: touch;
-}
-
-#toolsModal .nav-pills .nav-link {
- white-space: nowrap;
-}
-
-.table-action-group {
- display: flex;
- flex-wrap: wrap;
- gap: 0.25rem;
- align-items: center;
-}
-
-.table-action-group .btn {
- white-space: nowrap;
-}
-
-.backup-actions {
- justify-content: flex-end;
-}
-
-.backup-create-row {
- min-width: 0;
-}
-
-
-@media (max-width: 768px) {
- .profile-form-actions {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- width: 100%;
- }
-
- .profile-form-actions .btn {
- min-width: 0;
- width: 100%;
- }
-
- .backup-create-row {
- display: grid;
- gap: 0.45rem;
- }
-
- .backup-create-row > .form-control,
- .backup-create-row > .btn {
- width: 100%;
- }
-
- .backup-actions {
- justify-content: flex-start;
- }
-
- .table-action-group .btn {
- flex: 1 1 7.5rem;
- }
-}
-
-@media (max-width: 420px) {
- .profile-form-actions {
- grid-template-columns: 1fr;
- }
-}