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 ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(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?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=`
New API tokenThis token is shown once. Copy it now before refreshing the page.
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never';\n return `
${esc(t.name||'API token')}${esc(t.token_prefix||'')} · created ${humanDateCell(t.created_at)} · last used ${last}
`;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=`
API tokensActive 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=``+profiles.map(p=>``).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile '+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=` `;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),``,actions];\n });\n $('authUsersManager').innerHTML=rows.length?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}`;\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='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `
${esc(r.name)} ${enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`
${cards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Logs and historyPending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `
${esc(label)}${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n 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=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')])],\n ['poller','Poller', diagnosticsSection('Adaptive poller', [diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)])],\n ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', `
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`]\n ];\n const tabs=`
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? '-')}${note?`${esc(note)}`:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`
${panes.map(p=>`
${p[2].join('')}
`).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=``;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return ``;\n }\n function plannerToggleRow(id,title,description){\n return `
${title}${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `
\n ${title}\n ${sub}\n
Unlimited
\n
\n \n \n \n \n \n \n
\n
\n \n \n \n \n
\n Slider uses Mbit/s. Numeric fields store B/s for rTorrent.\n
`;\n }\n"; +export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:smartHistoryDetails(latest||{}).decision||latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return '
No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' · ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(d.decision||h.event||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : '
No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=`
New API tokenThis token is shown once. Copy it now before refreshing the page.
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never';\n return `
${esc(t.name||'API token')}${esc(t.token_prefix||'')} · created ${humanDateCell(t.created_at)} · last used ${last}
`;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=`
API tokensActive 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=``+profiles.map(p=>``).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile '+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=` `;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),``,actions];\n });\n $('authUsersManager').innerHTML=rows.length?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}`;\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='
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'
No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `
${esc(r.name)} ${enabled?'on':'off'}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
`;\n }).join(''):'
No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`
${cards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Logs and historyPending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `
${esc(label)}${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n 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=``+diskMonitorPaths.map(p=>``).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`
${esc(p)}${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
`).join(''):'
No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')])],\n ['poller','Poller', diagnosticsSection('Adaptive poller', [diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)])],\n ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', `
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? '-')}${note?`${esc(note)}`:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`
    ${panes.map(p=>`
  • `).join('')}
${panes.map(p=>`
${p[2].join('')}
`).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=``;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return ``;\n }\n function plannerToggleRow(id,title,description){\n return `
${title}${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return `
\n ${title}\n ${sub}\n
Unlimited
\n
\n \n \n \n \n \n \n
\n
\n \n \n \n \n
\n Slider uses Mbit/s. Numeric fields store B/s for rTorrent.\n
`;\n }\n"; diff --git a/pytorrent/static/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; - } -}