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 ignoredSeedPeer=(r.ignore_seed_peer||r.settings?.ignore_seed_peer)?Number(r.ignored_seed_peer_count||0):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 startSkipped=Number(r.start_source_skipped||0); const startSkippedTail=(!r.allow_start_without_sources&&!r.settings?.allow_start_without_sources&&startSkipped)?`, start skipped no sources ${startSkipped}`:\'\'; const ignoredSeedTail=(r.ignore_seed_peer||r.settings?.ignore_seed_peer)?`, ignored missing seeds/peers ${ignoredSeedPeer}`:\'\'; 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}${startSkippedTail}${ignoredSeedTail}${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 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($(\'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($(\'smartAllowStartWithoutSources\')) $(\'smartAllowStartWithoutSources\').checked=!!st.allow_start_without_sources;\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 $(\'smartManager\').innerHTML=ex.length\n ? responsiveTable([\'Hash\',\'Reason\',\'Created\',\'Action\'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||\'\'),dateCell(x.created_at),``]),\'smart-exclusions-table\')\n : \' No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
\';\n }\n if($(\'smartHistory\')){\n const body=hist.length\n ? responsiveTable([\'Time\',\'Event\',\'Checked\',\'Active\',\'Limit\',\'Over\',\'Stopped\',\'Requested\',\'Verified\',\'Pending\',\'Start failed\',\'No effect\',\'Stop failed\'],hist.map(h=>{ const d=smartHistoryDetails(h); return [dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(d.active_before??\'-\'),esc(d.max_active_downloads??\'-\'),esc(d.over_limit??0),esc(h.paused_count||0),esc((d.start_requested||d.started||[]).length||h.resumed_count||0),esc((d.active_verified||[]).length||0),esc((d.start_pending_confirmation||[]).length||0),esc((d.start_failed||[]).length||0),esc((d.start_no_effect||[]).length||0),esc((d.stop_failed||[]).length||0)]; }),\'smart-history-table\')\n : \'No Smart Queue operations yet.
\';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:\'\';\n $(\'smartHistory\').innerHTML=`${body}${toggle}`;\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 toast(\'No torrents selected\',\'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,min_speed_bytes:Math.round(Number($(\'smartMinSpeed\')?.value||0)*1024),min_seeds:$(\'smartMinSeeds\')?.value,min_peers:$(\'smartMinPeers\')?.value,allow_start_without_sources:$(\'smartAllowStartWithoutSources\')?.checked,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 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 return [esc(u.username),esc(u.role),u.is_active?\'yes\':\'no\',esc(perms),` `];\n });\n $(\'authUsersManager\').innerHTML=rows.length?table([\'User\',\'Role\',\'Active\',\'Profile rights\',\'Actions\'],rows):\'No users.
\';\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 toast(`rTorrent config saved (${j.result?.updated?.length||0})`,\'success\');\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\'); toast(`Automation logs deleted: ${j.deleted||0}`,\'success\'); 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 renderCleanup(data={}){\n const box=$(\'cleanupManager\'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cards=[\n cleanupCountCard(\'Job logs total\', data.jobs_total, `retention ${retention.jobs||\'-\'} days`),\n cleanupCountCard(\'Job logs clearable\', data.jobs_clearable, \'done / failed / cancelled\'),\n cleanupCountCard(\'Smart Queue logs\', data.smart_queue_history_total, `retention ${retention.smart_queue_history||\'-\'} days`),\n cleanupCountCard(\'Automation logs\', data.automation_history_total, `retention ${retention.automation_history||\'-\'} days`),\n cleanupCountCard(\'Database size\', db.size_h||db.size||\'-\', db.path||\'\')\n ];\n box.innerHTML=`${cards.join(\'\')}
Job cleanup preserves pending and running jobs. Automation cleanup removes only history, not rules.
`;\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 toast(`Cleanup done (${deleted})`,\'success\');\n renderCleanup(j.cleanup||{});\n if(endpoint.includes(\'/jobs\')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes(\'/smart-queue\')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes(\'/automations\')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,\'danger\'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=\'\'){ return ``; }\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 updateBrowserSpeedTitle(downH, upH){\n // Note: Shows DL/UL in the tab title in ruTorrent style; window.status is a fallback attempt for older browsers.\n if(downH != null) lastBrowserSpeed.down=downH || \'0 B/s\';\n if(upH != null) lastBrowserSpeed.up=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\')).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={...footerItems,...JSON.parse(prefs.footer_items_json||\'{}\')}; }catch(_){}\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 [j,smart]=await Promise.all([\n fetch(\'/api/app/status\').then(r=>r.json()),\n fetch(\'/api/smart-queue?history_limit=100\').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!j.ok) throw new Error(j.error||\'Failed to load diagnostics\');\n const st=j.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 smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const cards=[\n diagCard(\'pyTorrent PID\', py.pid), diagCard(\'pyTorrent uptime\', `${py.uptime_seconds||0}s`), diagCard(\'Memory RSS\', py.memory_rss_h||py.memory_rss),\n diagCard(\'Threads\', py.threads), diagCard(\'CPU\', `${py.cpu_percent ?? \'-\'}%`), diagCard(\'Jobs total\', py.jobs_total),\n diagCard(\'Worker threads\', py.worker_threads), diagCard(\'Python\', py.python||\'-\'), diagCard(\'DB size\', db.size_h||\'-\'),\n diagCard(\'Active profile\', profile.name||profile.id||\'-\'), diagCard(\'API response time\', `${st.api_ms ?? \'-\'} ms`),\n 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 diagCard(\'Job logs clearable\', cleanup.jobs_clearable ?? \'-\'), diagCard(\'Smart Queue logs\', cleanup.smart_queue_history_total ?? \'-\'), diagCard(\'Automation logs\', cleanup.automation_history_total ?? \'-\'),\n 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\')),\n 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`:\'-\'),\n 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`:\'-\'),\n 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 ];\n const smartBlock=` Smart Queue statistics
${renderSmartQueueNerdStats(smartStats)}`;\n box.innerHTML=`${cards.join(\'\')}
${smartBlock}${scgi.error?`${esc(scgi.error)}
`:\'\'}`;\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n\n function torrentStatsCard(label, value, note=\'\'){\n return `${esc(label)}${esc(value ?? \'-\')}${note?`${esc(note)}`:\'\'}
`;\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 cards=[\n torrentStatsCard(\'Torrents\', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\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 torrentStatsCard(\'Seeds / peers\', `${stats.seeds_total||0} / ${stats.peers_total||0}`, \'current sum from last sample\'),\n torrentStatsCard(\'Speed DL / UL\', `${stats.down_rate_total_h||\'0 B/s\'} / ${stats.up_rate_total_h||\'0 B/s\'}`),\n torrentStatsCard(\'Sampled\', stats.sampled_torrents ?? 0, stats.stale?\'cache is stale\':\'cache is fresh\')\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=`${cards.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 }\n';