diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py
index 6086809..6321263 100644
--- a/pytorrent/routes/_shared.py
+++ b/pytorrent/routes/_shared.py
@@ -22,7 +22,7 @@ from flask import Blueprint, jsonify, request, abort, send_file, redirect, Respo
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
from ..db import connect, utcnow
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
-from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner
+from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs
from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
@@ -288,6 +288,7 @@ def cleanup_summary() -> dict:
"WHERE profile_id=? OR profile_id IS NULL",
(profile_id,),
) if profile_id else _table_count("operation_logs")
+ operation_log_retention = operation_logs.get_settings(profile_id) if profile_id else operation_logs.get_settings(0)
return {
"jobs_total": _table_count("jobs"),
"jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
@@ -299,10 +300,14 @@ def cleanup_summary() -> dict:
"retention_days": {
"jobs": JOBS_RETENTION_DAYS,
"smart_queue_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
- "operation_logs": LOG_RETENTION_DAYS,
+ "operation_logs": operation_log_retention.get("retention_days", LOG_RETENTION_DAYS),
"automation_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
"planner_history": SMART_QUEUE_HISTORY_RETENTION_DAYS,
},
+ "operation_log_retention": operation_log_retention,
+ "retention_labels": {
+ "operation_logs": operation_logs.retention_label(operation_log_retention),
+ },
"database": _db_size(),
}
diff --git a/pytorrent/routes/operation_logs.py b/pytorrent/routes/operation_logs.py
index 7e257f6..9c217c6 100644
--- a/pytorrent/routes/operation_logs.py
+++ b/pytorrent/routes/operation_logs.py
@@ -23,6 +23,7 @@ def operation_logs_list():
offset=int(request.args.get("offset") or 0),
event_type=str(request.args.get("type") or "").strip(),
q=str(request.args.get("q") or "").strip(),
+ hide_jobs=str(request.args.get("hide_jobs") or "").lower() in {"1", "true", "yes", "on"},
)
data["stats"] = operation_logs.stats(int(profile["id"]))
data["settings"] = data["stats"].get("settings")
diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py
index bec4cdc..de39fe8 100644
--- a/pytorrent/services/operation_logs.py
+++ b/pytorrent/services/operation_logs.py
@@ -125,7 +125,7 @@ def record_cache_diff(profile_id: int, added: list[dict], removed: list[str], up
record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path")})
-def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: str = "", q: str = "") -> dict:
+def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: str = "", q: str = "", hide_jobs: bool = False) -> dict:
limit = max(1, min(int(limit or 200), 1000))
offset = max(0, int(offset or 0))
where = ["(profile_id=? OR profile_id IS NULL)"]
@@ -133,6 +133,9 @@ def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type:
if event_type:
where.append("event_type=?")
params.append(event_type)
+ if hide_jobs:
+ # Note: Job-originated rows include torrent_added/torrent_removed events, so source is the reliable filter.
+ where.append("COALESCE(source, '') <> 'job'")
if q:
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ?)")
like = f"%{q}%"
@@ -155,6 +158,17 @@ def stats(profile_id: int) -> dict:
return {"total": int(total or 0), "by_type": by_type, "by_day": by_day, "by_month": by_month, "top_actions": top_actions, "settings": get_settings(profile_id)}
+
+def retention_label(settings: dict) -> str:
+ mode = settings.get("retention_mode") or "days"
+ if mode == "manual":
+ return "manual cleanup only"
+ if mode == "lines":
+ return f"retention {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
+ if mode == "both":
+ return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days and {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
+ return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days"
+
def clear(profile_id: int, *, event_type: str = "") -> int:
where = ["(profile_id=? OR profile_id IS NULL)"]
params: list[Any] = [int(profile_id or 0)]
diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py
index eb16ea6..7f21020 100644
--- a/pytorrent/services/preferences.py
+++ b/pytorrent/services/preferences.py
@@ -38,6 +38,9 @@ RECOMMENDED_TABLE_COLUMNS = {
"created": False, "priority": False, "state": False, "active": False, "complete": False,
"hashing": False, "message": False, "hash": False,
},
+ "mobileSortFilters": {
+ "seeds:-1": True, "up_rate:-1": True, "down_rate:-1": True, "progress:-1": True,
+ },
"mobileSmartFiltersEnabled": False,
"widths": {
"select": 44, "name": 389, "status": 83, "size": 75, "progress": 177,
diff --git a/pytorrent/static/js/mobile.js b/pytorrent/static/js/mobile.js
index c9ef6e7..dbdd64c 100644
--- a/pytorrent/static/js/mobile.js
+++ b/pytorrent/static/js/mobile.js
@@ -1 +1 @@
-export const mobileSource = " // Note: Mobile-only filtering, sorting and card rendering lives here so torrent table code stays focused on desktop rows.\n function mobileSortDef(){ return MOBILE_SORT_STEPS.find(x=>x.key===sortState.key && x.dir===sortState.dir) || MOBILE_SORT_STEPS.find(x=>x.key===sortState.key) || MOBILE_SORT_STEPS[0]; }\n\n function mobileSortLabel(){ const def=mobileSortDef(); return `${def.label} ${sortState.dir>0?'↑':'↓'}`; }\n\n function mobileSortSelectOptions(){\n const defs = [['name','Name'], ...COLUMN_DEFS.map(([key,label]) => [key,label])];\n const seen = new Set();\n return defs.filter(([key]) => { if(seen.has(key)) return false; seen.add(key); return SORT_KEYS.has(key); });\n }\n\n function setMobileSortValue(value){\n const [key, dir] = String(value || 'name:1').split(':');\n if(!SORT_KEYS.has(key)) return;\n sortState = {key, dir: Number(dir) < 0 ? -1 : 1};\n saveTorrentSortPreference();\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n scheduleRender(true);\n }\n\n function cycleMobileSort(){\n const current=MOBILE_SORT_STEPS.findIndex(x=>x.key===sortState.key && x.dir===sortState.dir);\n const next=MOBILE_SORT_STEPS[(current+1) % MOBILE_SORT_STEPS.length];\n sortState={key:next.key, dir:next.dir};\n saveTorrentSortPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function setMobileFilterValue(value){\n const key=String(value||'all');\n mobileActiveFilterKey=key;\n if(key.startsWith('tracker:')){\n activeTrackerFilter=key.slice(8);\n activeFilter='all';\n }else{\n activeTrackerFilter='';\n activeFilter=key || 'all';\n }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function mobileFilterDefs(){\n const arr=trackerScopedRows();\n const defs=Object.keys(FILTER_COUNT_IDS).filter(k=>k!=='moving').map(k=>[k,k==='all'?'All':k==='downloading'?'Downloading':k==='seeding'?'Seeding':k==='paused'?'Paused':k==='checking'?'Checking':k==='error'?'With error':'Stopped',filterSummaryBucket(k).count||0]);\n const movingCount=movingFilterCount();\n if(movingCount) defs.push(['moving','Moving',movingCount]);\n const counts=new Map();\n arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label']));\n defs.push(['tracker:','All trackers',torrents.size,'tracker']);\n const trackerOptions=new Map((trackerSummary.trackers||[]).map(t=>[t.domain,t]));\n // Note: Preserve the selected tracker option even when the cache has not returned it yet, so the mobile select does not jump to All trackers.\n if(activeTrackerFilter && !trackerOptions.has(activeTrackerFilter)) trackerOptions.set(activeTrackerFilter, {domain:activeTrackerFilter, count:arr.length});\n [...trackerOptions.values()].forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker']));\n if(mobileSmartFiltersEnabled) SMART_VIEW_DEFS.forEach(([key,label])=>defs.push([key,label,arr.filter(t=>smartViewVisible(t,key)).length,'smart']));\n return defs;\n }\n\n function renderMobileFilters(){\n const bar=$('mobileFilterBar');\n if(!bar) return;\n const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash));\n const someVisible=visibleRows.some(t=>selected.has(t.hash));\n const defs=mobileFilterDefs();\n const currentSelect=$('mobileFilterSelect');\n const focused=currentSelect && document.activeElement===currentSelect;\n const sig=[focused ? 'focus' : activeFilter, activeTrackerFilter, sortState.key, sortState.dir, selected.size, allVisible ? 1 : 0, someVisible ? 1 : 0, mobileSmartFiltersEnabled ? 1 : 0, defs.map(d=>`${d[0]}:${d[2]}`).join('|')].join('::');\n if(focused) return;\n if(sig===lastMobileFiltersSignature) return;\n lastMobileFiltersSignature=sig;\n const selectedMobileKey = activeTrackerFilter ? `tracker:${activeTrackerFilter}` : (mobileActiveFilterKey === 'tracker:' ? 'tracker:' : activeFilter);\n // Note: Select exactly one mobile option; \"All\" and \"All trackers\" share the same data filter but must not both render as selected.\n const opts=defs.map(([key,label,count,type])=>{ const selectedOpt = key === selectedMobileKey; return `${type==='label'?'Label: ':type==='tracker'?'Tracker: ':type==='smart'?'Smart: ':''}${esc(label)} (${count}) `; }).join('');\n const bulk=selected.size?` Label Move .torrent `:'';\n // Note: Mobile sorting stays as a single cycle button while the step list covers every sortable column.\n bar.innerHTML=`
${allVisible?'Unselect all':'Select all'} Clear ${bulk}${selected.size} selected
Filter${opts}
Sort: ${esc(mobileSortLabel())}
`;\n }\n\n function mobileColumnValue(t, key){\n if(key==='status') return statusBadge(t);\n if(key==='size') return `Size ${esc(t.size_h||'-')}`;\n if(key==='progress') return null;\n if(key==='down_rate') return `DL ${esc(t.down_rate_h||'-')}`;\n if(key==='up_rate') return `UL ${esc(t.up_rate_h||'-')}`;\n if(key==='eta') return `ETA ${esc(t.eta_h||'-')}`;\n if(key==='seeds') return `Seeds ${esc(t.seeds??0)}`;\n if(key==='peers') return `Peers ${esc(t.peers??0)}`;\n if(key==='ratio') return `Ratio ${esc(t.ratio??'-')}`;\n if(key==='label') return `Label ${esc(t.label||'-')}`;\n if(key==='ratio_group') return `Ratio group ${esc(t.ratio_group||'-')}`;\n if(key==='down_total') return `Downloaded ${esc(t.down_total_h||'-')}`;\n // Note: Complete torrents hide this mobile line because there is nothing left to download.\n if(key==='to_download') return t.to_download_h ? `To download ${esc(t.to_download_h)}` : null;\n if(key==='up_total') return `Uploaded ${esc(t.up_total_h||'-')}`;\n if(key==='created') return `Added ${esc(formatDateTime(t.created))}`;\n if(key==='priority') return `Priority ${esc(t.priority ?? '-')}`;\n if(key==='state') return `State ${esc(t.state ?? '-')}`;\n if(key==='active') return `Active ${esc(t.active ?? '-')}`;\n if(key==='complete') return `Complete ${esc(t.complete ?? '-')}`;\n if(key==='hashing') return `Hashing ${esc(t.hashing ?? 0)}`;\n if(key==='message') return `Message ${esc(t.message||'-')}`;\n if(key==='hash') return `Hash ${esc(t.hash||'-')}`;\n return null;\n }\n\n function mobileInfoLines(t){\n const primary=[], secondary=[];\n MOBILE_COLUMN_DEFS.forEach(([key])=>{\n if(!mobileColumns[key]) return;\n const value = mobileColumnValue(t, key);\n if(!value) return;\n if(['status','size','ratio','eta','seeds','peers'].includes(key)) primary.push(value);\n else if(key!=='path') secondary.push(value);\n });\n return {primary:primary.join(' · '), secondary:secondary.join(' · ')};\n }\n";
+export const mobileSource = " // Note: Mobile-only filtering, sorting and card rendering lives here so torrent table code stays focused on desktop rows.\n function enabledMobileSortSteps(){\n const enabled = MOBILE_SORT_STEPS.filter(step => mobileSortFilters[mobileSortStepId(step)]);\n return enabled.length ? enabled : MOBILE_SORT_STEPS.filter(step => DEFAULT_MOBILE_SORT_FILTER_IDS.has(mobileSortStepId(step)));\n }\n\n function mobileSortDef(){\n const steps = enabledMobileSortSteps();\n return steps.find(x=>x.key===sortState.key && x.dir===sortState.dir) || steps.find(x=>x.key===sortState.key) || steps[0] || MOBILE_SORT_STEPS[0];\n }\n\n function mobileSortLabel(){ const def=mobileSortDef(); return `${def.label} ${def.dir>0?'↑':'↓'}`; }\n\n function mobileSortSelectOptions(){\n const defs = [['name','Name'], ...COLUMN_DEFS.map(([key,label]) => [key,label])];\n const seen = new Set();\n return defs.filter(([key]) => { if(seen.has(key)) return false; seen.add(key); return SORT_KEYS.has(key); });\n }\n\n function setMobileSortValue(value){\n const [key, dir] = String(value || 'name:1').split(':');\n if(!SORT_KEYS.has(key)) return;\n sortState = {key, dir: Number(dir) < 0 ? -1 : 1};\n saveTorrentSortPreference();\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n scheduleRender(true);\n }\n\n function cycleMobileSort(){\n const steps = enabledMobileSortSteps();\n const current=steps.findIndex(x=>x.key===sortState.key && x.dir===sortState.dir);\n const next=steps[(current+1) % steps.length] || mobileSortDef();\n sortState={key:next.key, dir:next.dir};\n saveTorrentSortPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function setMobileFilterValue(value){\n const key=String(value||'all');\n mobileActiveFilterKey=key;\n if(key.startsWith('tracker:')){\n activeTrackerFilter=key.slice(8);\n activeFilter='all';\n }else{\n activeTrackerFilter='';\n activeFilter=key || 'all';\n }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap'))$('tableWrap').scrollTop=0;\n if($('mobileList'))$('mobileList').scrollTop=0;\n scheduleRender(true);\n }\n\n function mobileFilterDefs(){\n const arr=trackerScopedRows();\n const defs=Object.keys(FILTER_COUNT_IDS).filter(k=>k!=='moving').map(k=>[k,k==='all'?'All':k==='downloading'?'Downloading':k==='seeding'?'Seeding':k==='paused'?'Paused':k==='checking'?'Checking':k==='error'?'With error':'Stopped',filterSummaryBucket(k).count||0]);\n const movingCount=movingFilterCount();\n if(movingCount) defs.push(['moving','Moving',movingCount]);\n const counts=new Map();\n arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label']));\n defs.push(['tracker:','All trackers',torrents.size,'tracker']);\n const trackerOptions=new Map((trackerSummary.trackers||[]).map(t=>[t.domain,t]));\n // Note: Preserve the selected tracker option even when the cache has not returned it yet, so the mobile select does not jump to All trackers.\n if(activeTrackerFilter && !trackerOptions.has(activeTrackerFilter)) trackerOptions.set(activeTrackerFilter, {domain:activeTrackerFilter, count:arr.length});\n [...trackerOptions.values()].forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker']));\n if(mobileSmartFiltersEnabled) SMART_VIEW_DEFS.forEach(([key,label])=>defs.push([key,label,arr.filter(t=>smartViewVisible(t,key)).length,'smart']));\n return defs;\n }\n\n function renderMobileFilters(){\n const bar=$('mobileFilterBar');\n if(!bar) return;\n const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash));\n const someVisible=visibleRows.some(t=>selected.has(t.hash));\n const defs=mobileFilterDefs();\n const currentSelect=$('mobileFilterSelect');\n const focused=currentSelect && document.activeElement===currentSelect;\n const sortSig = enabledMobileSortSteps().map(mobileSortStepId).join('|');\n const sig=[focused ? 'focus' : activeFilter, activeTrackerFilter, sortState.key, sortState.dir, selected.size, allVisible ? 1 : 0, someVisible ? 1 : 0, mobileSmartFiltersEnabled ? 1 : 0, sortSig, defs.map(d=>`${d[0]}:${d[2]}`).join('|')].join('::');\n if(focused) return;\n if(sig===lastMobileFiltersSignature) return;\n lastMobileFiltersSignature=sig;\n const selectedMobileKey = activeTrackerFilter ? `tracker:${activeTrackerFilter}` : (mobileActiveFilterKey === 'tracker:' ? 'tracker:' : activeFilter);\n // Note: Select exactly one mobile option; \"All\" and \"All trackers\" share the same data filter but must not both render as selected.\n const opts=defs.map(([key,label,count,type])=>{ const selectedOpt = key === selectedMobileKey; return `${type==='label'?'Label: ':type==='tracker'?'Tracker: ':type==='smart'?'Smart: ':''}${esc(label)} (${count}) `; }).join('');\n const bulk=selected.size?` Label Move .torrent `:'';\n // Note: Mobile sorting stays as a single cycle button while the step list covers every sortable column.\n bar.innerHTML=` ${allVisible?'Unselect all':'Select all'} Clear ${bulk}${selected.size} selected
Filter${opts}
Sort: ${esc(mobileSortLabel())}
`;\n }\n\n function mobileColumnValue(t, key){\n if(key==='status') return statusBadge(t);\n if(key==='size') return `Size ${esc(t.size_h||'-')}`;\n if(key==='progress') return null;\n if(key==='down_rate') return `DL ${esc(t.down_rate_h||'-')}`;\n if(key==='up_rate') return `UL ${esc(t.up_rate_h||'-')}`;\n if(key==='eta') return `ETA ${esc(t.eta_h||'-')}`;\n if(key==='seeds') return `Seeds ${esc(t.seeds??0)}`;\n if(key==='peers') return `Peers ${esc(t.peers??0)}`;\n if(key==='ratio') return `Ratio ${esc(t.ratio??'-')}`;\n if(key==='label') return `Label ${esc(t.label||'-')}`;\n if(key==='ratio_group') return `Ratio group ${esc(t.ratio_group||'-')}`;\n if(key==='down_total') return `Downloaded ${esc(t.down_total_h||'-')}`;\n // Note: Complete torrents hide this mobile line because there is nothing left to download.\n if(key==='to_download') return t.to_download_h ? `To download ${esc(t.to_download_h)}` : null;\n if(key==='up_total') return `Uploaded ${esc(t.up_total_h||'-')}`;\n if(key==='created') return `Added ${esc(formatDateTime(t.created))}`;\n if(key==='priority') return `Priority ${esc(t.priority ?? '-')}`;\n if(key==='state') return `State ${esc(t.state ?? '-')}`;\n if(key==='active') return `Active ${esc(t.active ?? '-')}`;\n if(key==='complete') return `Complete ${esc(t.complete ?? '-')}`;\n if(key==='hashing') return `Hashing ${esc(t.hashing ?? 0)}`;\n if(key==='message') return `Message ${esc(t.message||'-')}`;\n if(key==='hash') return `Hash ${esc(t.hash||'-')}`;\n return null;\n }\n\n function mobileInfoLines(t){\n const primary=[], secondary=[];\n MOBILE_COLUMN_DEFS.forEach(([key])=>{\n if(!mobileColumns[key]) return;\n const value = mobileColumnValue(t, key);\n if(!value) return;\n if(['status','size','ratio','eta','seeds','peers'].includes(key)) primary.push(value);\n else if(key!=='path') secondary.push(value);\n });\n return {primary:primary.join(' · '), secondary:secondary.join(' · ')};\n }\n";
diff --git a/pytorrent/static/js/modals.js b/pytorrent/static/js/modals.js
index 8ca52ff..c66ee70 100644
--- a/pytorrent/static/js/modals.js
+++ b/pytorrent/static/js/modals.js
@@ -1 +1 @@
-export const modalsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)} `);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used `);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs `);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files `);\n return meta.length ? `${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`${esc(d.name)}
`).join('')||'No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n function columnPrefsPayload(){\n return JSON.stringify({hidden:cleanColumnPrefsHidden(hiddenColumns), shown:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS).filter(key => !hiddenColumns.has(key)), mobile:mobileColumns, mobileSmartFiltersEnabled, widths:columnWidths});\n }\n function parseTableColumnsPreference(value){\n if(!value) return {};\n if(typeof value === 'object') return value;\n try{ return JSON.parse(value); }catch(e){ return {}; }\n }\n function applyTableColumnsPreference(value){\n const prefs = parseTableColumnsPreference(value);\n const explicitlyShown = new Set(prefs.shown || []);\n hiddenColumns = new Set([...(prefs.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShown.has(key))]);\n mobileColumns = normalizeMobileColumns(prefs.mobile || {});\n mobileSmartFiltersEnabled = prefs.mobileSmartFiltersEnabled ?? true;\n columnWidths = normalizeColumnWidths(prefs.widths || {});\n saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths});\n }\n function renderColumnCards(defs, values, inputClass, dataAttr, icon){\n return defs.map(([key,label,hiddenByDefault])=>{\n const active = !!values[key];\n return ` ${esc(label)} `;\n }).join('');\n }\n function renderColumnManager(){\n const box=$('columnManager');\n if(!box) return;\n const desktopValues = Object.fromEntries(COLUMN_DEFS.map(([key])=>[key, !hiddenColumns.has(key)]));\n const desktop = renderColumnCards(COLUMN_DEFS, desktopValues, 'column-toggle', 'data-col-key', 'fa-table-columns');\n const mobile = renderColumnCards(MOBILE_COLUMN_DEFS, mobileColumns, 'mobile-column-toggle', 'data-mobile-col-key', 'fa-mobile-screen');\n const smart = ` Smart filters `;\n box.innerHTML=` Desktop Mobile
`;\n }\n $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); document.querySelectorAll('.mobile-column-toggle').forEach(cb=>mobileColumns[cb.dataset.mobileColKey]=cb.checked); mobileSmartFiltersEnabled = $('mobileSmartFiltersToggle')?.checked ?? true; saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths}); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(e=>toast(e.message,'danger')); toastMessage('toast.columnsSaved','success'); });\n $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS); mobileColumns = normalizeMobileColumns(); mobileSmartFiltersEnabled = true; columnWidths = normalizeColumnWidths(); saveBrowserViewPrefs({mobileColumns, mobileSmartFiltersEnabled, columnWidths}); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(()=>{}); });\n $('recommendedColumnsBtn')?.addEventListener('click',async()=>{\n try{\n // Note: The recommended layout is applied by the backend and includes desktop, mobile and widths.\n const j = await post('/api/preferences/table-columns/recommended', {});\n applyTableColumnsPreference(j.preferences?.table_columns_json);\n renderColumnManager();\n applyColumnVisibility();\n scheduleRender(true);\n toastMessage('toast.recommendedColumnsApplied','success');\n }catch(e){\n toast(e.message,'danger');\n }\n });\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(` retry `); if(status==='pending') actions.push(` force `); if(status==='pending'||status==='running') actions.push(` emergency cancel `); return actions.join(' ') || '- '; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `bulk ${esc(count)} torrent(s), details hidden `;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join(' ') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)} `,\n r.source==='automation'?` automation `:(r.is_forced?' forced ':'user '),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=` PrevPage ${jobsPage+1} / ${pages} \u00b7 ${jobsTotal} jobs =pages-1?'disabled':''}>Next
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toastMessage('toast.jobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toastMessage('toast.emergencyJobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>` ${esc(l.name)} Remove
`).join(''):'No labels. Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>` ${esc(l)} `).join('') || 'No labels selected. '; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>` ${esc(l.name)} `).join('') || 'No saved labels. '; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)}) `).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`Groups ${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}Applied history ${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
+export const modalsSource = " function copyText(text){\n text=String(text ?? '');\n if(navigator.clipboard && window.isSecureContext){\n return navigator.clipboard.writeText(text);\n }\n return new Promise((resolve,reject)=>{\n const ta=document.createElement('textarea');\n ta.value=text; ta.setAttribute('readonly','');\n ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';\n document.body.appendChild(ta); ta.focus(); ta.select();\n try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }\n catch(e){ reject(e); }\n finally{ ta.remove(); }\n });\n }\n function copySelected(field){\n const t=torrents.get(selectedHash);\n if(!t) return toast('No torrent selected','warning');\n const value=String(t[field] ?? '');\n if(!value) return toast(`No ${field} to copy`,'warning');\n copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));\n }\n\n async function getDefaultDownloadPath(){ if(defaultDownloadPath) return defaultDownloadPath; try{ const j=await (await fetch('/api/path/default')).json(); if(j.ok && j.path) defaultDownloadPath=j.path; }catch(e){} return defaultDownloadPath || '/'; }\n async function applyDefaultDownloadPath(force=false){ const p=await getDefaultDownloadPath(); ['addPath','rssPath','autoEffectPath'].forEach(id=>{ const el=$(id); if(el && (force || !el.value)) el.value=p; }); return p; }\n async function openPathPicker(target){\n pathTarget=target;\n const modal=$('pathModal');\n if(!modal) return toastMessage('toast.pathPickerUnavailable','danger');\n const def=await getDefaultDownloadPath();\n const initial=def || ($(target)?.value||'/');\n // Note: The same modal is used for Move and simple path selection; only Move shows extra options.\n $('moveOptions')?.classList.toggle('d-none', target!=='move');\n if($('moveDataPhysical')) $('moveDataPhysical').checked=true;\n if($('moveRecheck')) $('moveRecheck').checked=true;\n // Note: The path picker can be opened from Add/Create modals, so it must sit above the parent modal.\n modal.classList.toggle('path-picker-stacked', document.querySelectorAll('.modal.show').length > 0);\n new bootstrap.Modal(modal).show();\n browsePath(initial);\n }\n function pathInfoHtml(j){\n // Note: Move modal shows remote-side capacity and entry counts before queuing a move.\n const meta=[];\n if(j.free_h) meta.push(` Free ${esc(j.free_h)} `);\n if(j.used_percent!==undefined) meta.push(`${esc(j.used_percent)}% used `);\n if(j.dir_count!==undefined) meta.push(`${esc(j.dir_count)} dirs `);\n if(j.file_count!==undefined) meta.push(`${esc(j.file_count)} files `);\n return meta.length ? `${meta.join('')}
` : '';\n }\n async function browsePath(path){\n const list=$('pathList');\n const current=$('pathCurrent');\n if(!list || !current) return;\n list.innerHTML=' Loading...';\n try{\n const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`);\n const j=await res.json();\n if(!j.ok) throw new Error(j.error);\n current.value=j.path;\n lastPathParent=j.parent;\n const rows=j.dirs.map(d=>`${esc(d.name)}
`).join('')||'No directories.
';\n list.innerHTML=pathInfoHtml(j)+rows;\n }catch(e){\n list.innerHTML=`${esc(e.message)}
`;\n }\n }\n $('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);});\n $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent));\n $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent')?.value));\n $('pathSelectBtn')?.addEventListener('click',async()=>{\n const p=($('pathCurrent')?.value||'').trim();\n if(!p) return toastMessage('toast.pathEmpty','warning');\n if(pathTarget==='move'){\n const hashes=selectedHashes();\n if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning');\n const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)});\n markQueuedJobs(j,hashes,'move');\n const parts=Number(j.bulk_parts||1);\n toastMessage('toast.moveQueued','success',{parts,physical:$('moveDataPhysical')?.checked});\n } else if($(pathTarget)) {\n $(pathTarget).value=p;\n }\n bootstrap.Modal.getInstance($('pathModal'))?.hide();\n });\n document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));\n\n function columnPrefsPayload(){\n return JSON.stringify({hidden:cleanColumnPrefsHidden(hiddenColumns), shown:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS).filter(key => !hiddenColumns.has(key)), mobile:mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, widths:columnWidths});\n }\n function parseTableColumnsPreference(value){\n if(!value) return {};\n if(typeof value === 'object') return value;\n try{ return JSON.parse(value); }catch(e){ return {}; }\n }\n function applyTableColumnsPreference(value){\n const prefs = parseTableColumnsPreference(value);\n const explicitlyShown = new Set(prefs.shown || []);\n hiddenColumns = new Set([...(prefs.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShown.has(key))]);\n mobileColumns = normalizeMobileColumns(prefs.mobile || {});\n mobileSortFilters = normalizeMobileSortFilters(prefs.mobileSortFilters || {});\n mobileSmartFiltersEnabled = prefs.mobileSmartFiltersEnabled ?? true;\n columnWidths = normalizeColumnWidths(prefs.widths || {});\n saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths});\n }\n function renderColumnCards(defs, values, inputClass, dataAttr, icon){\n return defs.map(([key,label,hiddenByDefault])=>{\n const active = !!values[key];\n return ` ${esc(label)} `;\n }).join('');\n }\n function renderMobileSortFilterCards(){\n return MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n const active = !!mobileSortFilters[id];\n const direction = step.dir > 0 ? 'ascending' : 'descending';\n return ` ${esc(step.label)} ${esc(direction)} `;\n }).join('');\n }\n function renderColumnManager(){\n const box=$('columnManager');\n if(!box) return;\n const desktopValues = Object.fromEntries(COLUMN_DEFS.map(([key])=>[key, !hiddenColumns.has(key)]));\n const desktop = renderColumnCards(COLUMN_DEFS, desktopValues, 'column-toggle', 'data-col-key', 'fa-table-columns');\n const mobile = renderColumnCards(MOBILE_COLUMN_DEFS, mobileColumns, 'mobile-column-toggle', 'data-mobile-col-key', 'fa-mobile-screen');\n const mobileSort = renderMobileSortFilterCards();\n const smart = ` Smart filters `;\n box.innerHTML=` Desktop Mobile
Mobile sort filters Only enabled sort choices appear in the mobile Sort button. Defaults keep seeds, upload, download and progress descending. ${mobileSort}
Mobile filter groups ${smart}
`;\n }\n $('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); document.querySelectorAll('.mobile-column-toggle').forEach(cb=>mobileColumns[cb.dataset.mobileColKey]=cb.checked); document.querySelectorAll('.mobile-sort-filter-toggle').forEach(cb=>mobileSortFilters[cb.dataset.mobileSortFilter]=cb.checked); mobileSmartFiltersEnabled = $('mobileSmartFiltersToggle')?.checked ?? true; saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(e=>toast(e.message,'danger')); toastMessage('toast.columnsSaved','success'); });\n $('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS); mobileColumns = normalizeMobileColumns(); mobileSortFilters = normalizeMobileSortFilters(); mobileSmartFiltersEnabled = true; columnWidths = normalizeColumnWidths(); saveBrowserViewPrefs({mobileColumns, mobileSortFilters, mobileSmartFiltersEnabled, columnWidths}); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:columnPrefsPayload()}).catch(()=>{}); });\n $('recommendedColumnsBtn')?.addEventListener('click',async()=>{\n try{\n // Note: The recommended layout is applied by the backend and includes desktop, mobile and widths.\n const j = await post('/api/preferences/table-columns/recommended', {});\n applyTableColumnsPreference(j.preferences?.table_columns_json);\n renderColumnManager();\n applyColumnVisibility();\n scheduleRender(true);\n toastMessage('toast.recommendedColumnsApplied','success');\n }catch(e){\n toast(e.message,'danger');\n }\n });\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(` retry `); if(status==='pending') actions.push(` force `); if(status==='pending'||status==='running') actions.push(` emergency cancel `); return actions.join(' ') || '- '; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `bulk ${esc(count)} torrent(s), details hidden `;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join(' ') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)} `,\n r.source==='automation'?` automation `:(r.is_forced?' forced ':'user '),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=` PrevPage ${jobsPage+1} / ${pages} · ${jobsTotal} jobs =pages-1?'disabled':''}>Next
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toastMessage('toast.jobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toastMessage('toast.emergencyJobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n async function loadLabels(){ const j=await (await fetch('/api/labels')).json(); const labels=j.labels||[]; knownLabels=labels; renderLabelFilters(); renderLabelChooser(); if($('labelsManager')) $('labelsManager').innerHTML=labels.length?labels.map(l=>` ${esc(l.name)} Remove
`).join(''):'No labels. Add first label above.
'; }\n function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>` ${esc(l)} `).join('') || 'No labels selected. '; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>` ${esc(l.name)} `).join('') || 'No saved labels. '; }\n async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }\n async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)}) `).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`Groups ${table(['Name','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}Applied history ${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
diff --git a/pytorrent/static/js/operationLogs.js b/pytorrent/static/js/operationLogs.js
index 75ac05e..9e4953a 100644
--- a/pytorrent/static/js/operationLogs.js
+++ b/pytorrent/static/js/operationLogs.js
@@ -1 +1 @@
-export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')} `;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `${esc(label)} ${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `${esc(x.bucket)} ${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `${esc(x.bucket)} ${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `${esc(x.action)} ${esc(x.n)}
`).join('');\n return `${card('Total logs', stats.total || 0)}${types}
Daily count ${daily || 'No data. '}Monthly count ${monthly || 'No data. '}Top actions ${actions || 'No data. '}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const type = $('operationLogTypeFilter')?.value || '';\n const q = $('operationLogSearch')?.value || '';\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n return params.toString();\n }\n\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n const types = ['','torrent_added','torrent_removed','torrent_completed','job_started','job_done','job_failed'];\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n $('operationLogTypeFilter').innerHTML = types.map(t => `${t ? esc(t) : 'All types'} `).join('');\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n compactCell(r.details_h || '', 220),\n ]), 'operation-log-table');\n if(!rows.length) box.innerHTML = 'No logs. No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Prev Page ${operationLogsPage + 1} / ${pages} \u00b7 ${total} logs = pages - 1 ? 'disabled' : ''}>Next `;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n $('logsModal')?.addEventListener('show.bs.modal', () => loadOperationLogs(true));\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n";
+export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_started', 'job_done', 'job_failed'];\n function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')} `;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `${esc(label)} ${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `${esc(x.bucket)} ${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `${esc(x.bucket)} ${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `${esc(x.action)} ${esc(x.n)}
`).join('');\n return `${card('Total logs', stats.total || 0)}${types}
Daily count ${daily || 'No data. '}Monthly count ${monthly || 'No data. '}Top actions ${actions || 'No data. '}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => `${t ? esc(t) : 'All types'} `).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n compactCell(r.details_h || '', 220),\n ]), 'operation-log-table');\n if(!rows.length) box.innerHTML = 'No logs. No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Prev Page ${operationLogsPage + 1} / ${pages} · ${total} logs = pages - 1 ? 'disabled' : ''}>Next `;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n";
diff --git a/pytorrent/static/js/smartQueue.js b/pytorrent/static/js/smartQueue.js
index d53445c..09b3190 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(' \u00b7 ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return `${esc(t.name||hash)} ${esc(smartQueueTorrentLabel(t))} `;\n }).join('') : 'No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),` remove exception `]),'smart-exclusions-table')\n : ' No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(h.event||d.decision||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : 'No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?` ${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)}) `:'';\n const clear=totalHistory?` Clear history `:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=` New API tokenThis token is shown once. Copy it now before refreshing the page.
Copy
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never ';\n return `${esc(t.name||'API token')} ${esc(t.token_prefix||'')} \u00b7 created ${humanDateCell(t.created_at)} \u00b7 last used ${last}
Delete `;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=` API tokensActive and revoked tokens for this user. Secrets are never shown after creation.
${tokens.length ? tokens.map(t=>tokenRow(t,userId)).join('') : 'No API tokens.
'}`;\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n box.querySelectorAll('.auth-token-delete').forEach(btn=>btn.addEventListener('click',async()=>{ if(!confirm('Delete this API token?')) return; await deleteAuthToken(btn.dataset.userId, btn.dataset.tokenId); await showAuthTokens(btn.dataset.userId); }));\n }catch(e){ toast(e.message,'danger'); }\n }\n async function deleteAuthToken(userId, tokenId){\n // Note: Token revocation uses the existing DELETE API and refreshes both token and user counts.\n const j=await post(`/api/auth/users/${userId}/tokens/${tokenId}`, {}, 'DELETE');\n toast('API token deleted','success');\n await loadAuthUsers();\n return j;\n }\n async function loadAuthUsers(){\n if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;\n const [usersRes, profilesRes]=await Promise.all([fetch('/api/auth/users'), fetch('/api/profiles')]);\n const usersJson=await usersRes.json();\n const profilesJson=await profilesRes.json();\n const profiles=profilesJson.profiles||[];\n if($('authProfile')) $('authProfile').innerHTML=`All profiles `+profiles.map(p=>`${esc(p.name)} `).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile '+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=` Generate token Tokens Remove `;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),`${esc(tokenText)} `,actions];\n });\n $('authUsersManager').innerHTML=rows.length?table(['User','Role','Active','Profile rights','API tokens','Actions'],rows):'No users.
';\n }\n async function generateAuthToken(userId){\n const name=prompt('Token name', 'API token');\n if(name===null) return;\n try{\n const j=await post(`/api/auth/users/${userId}/tokens`, {name:name||'API token'});\n const token=j.token?.token||'';\n renderGeneratedToken(token);\n await copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy the API token from the Users panel','warning'));\n await loadAuthUsers();\n }catch(e){ toast(e.message,'danger'); }\n }\n function resetAuthUserForm(){ ['authUserId','authUsername','authPassword'].forEach(id=>{ if($(id)) $(id).value=''; }); if($('authRole')) $('authRole').value='user'; if($('authProfile')) $('authProfile').value='0'; if($('authAccess')) $('authAccess').value='ro'; if($('authActive')) $('authActive').checked=true; $('authUserCancelBtn')?.classList.add('d-none'); }\n function editAuthUser(user){ if(!user) return; if($('authUserId')) $('authUserId').value=user.id||''; if($('authUsername')) $('authUsername').value=user.username||''; if($('authPassword')) $('authPassword').value=''; if($('authRole')) $('authRole').value=user.role||'user'; if($('authActive')) $('authActive').checked=!!user.is_active; const perm=(user.permissions||[])[0]||{profile_id:0,access_level:'ro'}; if($('authProfile')) $('authProfile').value=String(perm.profile_id||0); if($('authAccess')) $('authAccess').value=perm.access_level||'ro'; $('authUserCancelBtn')?.classList.remove('d-none'); }\n async function saveAuthUser(){\n const id=$('authUserId')?.value||'';\n const role=$('authRole')?.value||'user';\n const payload={username:$('authUsername')?.value||'',password:$('authPassword')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\n try{ await post(id?`/api/auth/users/${id}`:'/api/auth/users',payload,id?'PUT':'POST'); toast('User saved','success'); resetAuthUserForm(); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); }\n }\n function normalizeRtConfigValue(value, type='text'){\n const raw=String(value ?? '').trim();\n if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';\n if(type==='number'){\n if(raw==='') return '0';\n const normalized=Number(raw.replace(',', '.'));\n return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;\n }\n return raw;\n }\n function rtConfigInputValue(input){\n const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';\n const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;\n return normalizeRtConfigValue(value, type);\n }\n function rtConfigOriginalValue(input){\n const key=input.dataset.key;\n return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');\n }\n function collectRtConfigChanges(){\n const values={};\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled) return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur!==orig) values[input.dataset.key]=cur;\n });\n return values;\n }\n function collectRtConfigClearKeys(){\n const keys=[];\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled || input.dataset.saved!=='true') return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur===orig) keys.push(input.dataset.key);\n });\n return keys;\n }\n function updateRtConfigDirty(){\n const changed=collectRtConfigChanges();\n const clearKeys=collectRtConfigClearKeys();\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n const row=input.closest('.rt-config-row');\n if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));\n });\n const configChanges=Object.keys(changed).length;\n const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;\n const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);\n if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';\n if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;\n if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;\n }\n async function loadRtConfig(){\n const box=$('rtConfigManager');\n if(!box)return;\n box.innerHTML=' Loading config...';\n try{\n const j=await (await fetch('/api/rtorrent-config')).json();\n if(!j.ok) throw new Error(j.error||'Config load failed');\n const fields=j.config?.fields||[];\n rtConfigOriginal=new Map();\n rtConfigFieldTypes=new Map();\n rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;\n let lastGroup='';\n const html=fields.map(f=>{\n const group=f.group||'Other';\n const head=group!==lastGroup?`${esc(group)}
`:'';\n lastGroup=group;\n const disabled=(!f.ok||f.readonly)?'disabled':'';\n const type=['bool','number'].includes(f.type)?f.type:'text';\n const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);\n const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);\n rtConfigOriginal.set(f.key, originalValue);\n rtConfigFieldTypes.set(f.key, type);\n const note=f.ok?(f.readonly?' \u00b7 read only':(f.saved?' \u00b7 saved override \u00b7 reference kept':'')):' \u00b7 unavailable';\n const valueNote=f.saved?`Reference: ${esc(originalValue)} \u2192 saved: ${esc(displayValue)} `:'';\n const originalAttr=esc(originalValue);\n const input=type==='bool'\n ? `${displayValue==='1'?'On':'Off'} `\n : ` `;\n return `${head}${esc(f.label)} ${esc(f.key)}${note} ${valueNote} ${input} `;\n }).join('');\n box.innerHTML=`${html}
`;\n if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;\n updateRtConfigDirty();\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n async function saveRtConfig(){\n const values=collectRtConfigChanges();\n const clear_keys=collectRtConfigClearKeys();\n clear_keys.forEach(key=>{\n const input=document.querySelector(`.rt-config-input[data-key=\"${CSS.escape(key)}\"]`);\n if(input) values[key]=rtConfigOriginalValue(input);\n });\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});\n toastMessage('toast.rtorrentConfigSaved','success',{updated:j.result?.updated?.length});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function resetRtConfig(){\n // Note: Reset clears only saved UI overrides, then reloads the live state from rTorrent.\n if(!confirm('Clear all saved rTorrent UI overrides and reload current rTorrent values?')) return;\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config/reset',{});\n toastMessage('toast.rtorrentConfigReset','success',{removed:j.config?.reset_removed});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }\n\n function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){ bootstrapTheme = theme || \"default\"; const link=$(\"bootstrapThemeStylesheet\"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme; }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n\n function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' \u2192 ')||'no actions';\n return `${cs} \u2192 ${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(' \u00b7 ')||'action')} `;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `${summary||'No actions'} ${details} `;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar=' Clear history
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `${esc(r.name)} ${enabled?'on ':'off '}
${esc(ruleSummary(r))} \u00b7 cooldown ${esc(r.cooldown_minutes||0)} min
Remove
`;\n }).join(''):'No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `${esc(label)} ${esc(value ?? 0)} ${note?`${esc(note)} `:''}
`;\n }\n function 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, `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('Operation logs', data.operation_logs_total, `retention ${retention.operation_logs||'-'} days`),\n cleanupCountCard('Planner logs', data.planner_history_total, `retention ${retention.planner_history||'-'} days`),\n cleanupCountCard('Automation logs', data.automation_history_total, `retention ${retention.automation_history||'-'} days`),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`${cards.join('')}
Profile cache Clears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Clear profile cacheLogs and history Pending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
Clear job logs Clear Smart Queue logs Clear operation logs Clear Planner logs Clear automation logs Clear all logs
Refresh
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `${esc(label)} ${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function updateFooterClock(){\n const el=$('statClock');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});\n }\n function updateSocketStatus(s={}){\n const el=$('statSockets');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);\n const box=$('statusSockets');\n if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;\n }\n\n function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }\n function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }\n function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }\n function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)} `; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(' \u00b7 ') || 'Port check disabled';\n if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id=\"portCheckBadge\" ');\n if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' \u00b7 ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';\n if($('statusPortCheck')){\n $('statusPortCheck').classList.toggle('d-none', !data.enabled);\n $('statusPortCheck').title=title;\n }\n if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id=\"statusPortCheckBadge\" ',true);\n }\n async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\n function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=`${fallback} `+diskMonitorPaths.map(p=>`${esc(p)} `).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`${esc(p)} ${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
Use
`).join(''):'No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')])],\n ['poller','Poller', diagnosticsSection('Adaptive poller', [diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)])],\n ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', ` Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)} `]\n ];\n const tabs=`${panes.map((p,i)=>`${p[1]} `).join('')} `;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`${p[2]}
`).join('')}${scgi.error?`${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `${esc(label)} ${esc(value ?? '-')} ${note?`${esc(note)} `:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`${panes.map(p=>`${p[1]} `).join('')} ${panes.map(p=>``).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=` ${label} `;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return `${label} `;\n }\n function plannerToggleRow(id,title,description){\n return `${title} ${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return ``;\n }\n";
+export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return 'No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `${cards.map(([label,value,hint])=>`
${esc(label)} ${esc(value)} ${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' · ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return `${esc(t.name||hash)} ${esc(smartQueueTorrentLabel(t))} `;\n }).join('') : 'No torrents are loaded for this profile.
';\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n row.classList.toggle('d-none', query && !row.textContent.toLowerCase().includes(query));\n });\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),` remove exception `]),'smart-exclusions-table')\n : ' No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n return [dateCell(h.created_at),esc(h.event||d.decision||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : 'No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?` ${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)}) `:'';\n const clear=totalHistory?` Clear history `:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n\n function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=` New API tokenThis token is shown once. Copy it now before refreshing the page.
Copy
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never ';\n return `${esc(t.name||'API token')} ${esc(t.token_prefix||'')} · created ${humanDateCell(t.created_at)} · last used ${last}
Delete `;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=` API tokensActive and revoked tokens for this user. Secrets are never shown after creation.
${tokens.length ? tokens.map(t=>tokenRow(t,userId)).join('') : 'No API tokens.
'}`;\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n box.querySelectorAll('.auth-token-delete').forEach(btn=>btn.addEventListener('click',async()=>{ if(!confirm('Delete this API token?')) return; await deleteAuthToken(btn.dataset.userId, btn.dataset.tokenId); await showAuthTokens(btn.dataset.userId); }));\n }catch(e){ toast(e.message,'danger'); }\n }\n async function deleteAuthToken(userId, tokenId){\n // Note: Token revocation uses the existing DELETE API and refreshes both token and user counts.\n const j=await post(`/api/auth/users/${userId}/tokens/${tokenId}`, {}, 'DELETE');\n toast('API token deleted','success');\n await loadAuthUsers();\n return j;\n }\n async function loadAuthUsers(){\n if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;\n const [usersRes, profilesRes]=await Promise.all([fetch('/api/auth/users'), fetch('/api/profiles')]);\n const usersJson=await usersRes.json();\n const profilesJson=await profilesRes.json();\n const profiles=profilesJson.profiles||[];\n if($('authProfile')) $('authProfile').innerHTML=`All profiles `+profiles.map(p=>`${esc(p.name)} `).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile '+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=` Generate token Tokens Remove `;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),`${esc(tokenText)} `,actions];\n });\n $('authUsersManager').innerHTML=rows.length?table(['User','Role','Active','Profile rights','API tokens','Actions'],rows):'No users.
';\n }\n async function generateAuthToken(userId){\n const name=prompt('Token name', 'API token');\n if(name===null) return;\n try{\n const j=await post(`/api/auth/users/${userId}/tokens`, {name:name||'API token'});\n const token=j.token?.token||'';\n renderGeneratedToken(token);\n await copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy the API token from the Users panel','warning'));\n await loadAuthUsers();\n }catch(e){ toast(e.message,'danger'); }\n }\n function resetAuthUserForm(){ ['authUserId','authUsername','authPassword'].forEach(id=>{ if($(id)) $(id).value=''; }); if($('authRole')) $('authRole').value='user'; if($('authProfile')) $('authProfile').value='0'; if($('authAccess')) $('authAccess').value='ro'; if($('authActive')) $('authActive').checked=true; $('authUserCancelBtn')?.classList.add('d-none'); }\n function editAuthUser(user){ if(!user) return; if($('authUserId')) $('authUserId').value=user.id||''; if($('authUsername')) $('authUsername').value=user.username||''; if($('authPassword')) $('authPassword').value=''; if($('authRole')) $('authRole').value=user.role||'user'; if($('authActive')) $('authActive').checked=!!user.is_active; const perm=(user.permissions||[])[0]||{profile_id:0,access_level:'ro'}; if($('authProfile')) $('authProfile').value=String(perm.profile_id||0); if($('authAccess')) $('authAccess').value=perm.access_level||'ro'; $('authUserCancelBtn')?.classList.remove('d-none'); }\n async function saveAuthUser(){\n const id=$('authUserId')?.value||'';\n const role=$('authRole')?.value||'user';\n const payload={username:$('authUsername')?.value||'',password:$('authPassword')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\n try{ await post(id?`/api/auth/users/${id}`:'/api/auth/users',payload,id?'PUT':'POST'); toast('User saved','success'); resetAuthUserForm(); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); }\n }\n function normalizeRtConfigValue(value, type='text'){\n const raw=String(value ?? '').trim();\n if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';\n if(type==='number'){\n if(raw==='') return '0';\n const normalized=Number(raw.replace(',', '.'));\n return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;\n }\n return raw;\n }\n function rtConfigInputValue(input){\n const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';\n const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;\n return normalizeRtConfigValue(value, type);\n }\n function rtConfigOriginalValue(input){\n const key=input.dataset.key;\n return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');\n }\n function collectRtConfigChanges(){\n const values={};\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled) return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur!==orig) values[input.dataset.key]=cur;\n });\n return values;\n }\n function collectRtConfigClearKeys(){\n const keys=[];\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n if(input.disabled || input.dataset.saved!=='true') return;\n const cur=rtConfigInputValue(input);\n const orig=rtConfigOriginalValue(input);\n if(cur===orig) keys.push(input.dataset.key);\n });\n return keys;\n }\n function updateRtConfigDirty(){\n const changed=collectRtConfigChanges();\n const clearKeys=collectRtConfigClearKeys();\n document.querySelectorAll('.rt-config-input').forEach(input=>{\n const row=input.closest('.rt-config-row');\n if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));\n });\n const configChanges=Object.keys(changed).length;\n const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;\n const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);\n if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';\n if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;\n if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;\n }\n async function loadRtConfig(){\n const box=$('rtConfigManager');\n if(!box)return;\n box.innerHTML=' Loading config...';\n try{\n const j=await (await fetch('/api/rtorrent-config')).json();\n if(!j.ok) throw new Error(j.error||'Config load failed');\n const fields=j.config?.fields||[];\n rtConfigOriginal=new Map();\n rtConfigFieldTypes=new Map();\n rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;\n let lastGroup='';\n const html=fields.map(f=>{\n const group=f.group||'Other';\n const head=group!==lastGroup?`${esc(group)}
`:'';\n lastGroup=group;\n const disabled=(!f.ok||f.readonly)?'disabled':'';\n const type=['bool','number'].includes(f.type)?f.type:'text';\n const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);\n const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);\n rtConfigOriginal.set(f.key, originalValue);\n rtConfigFieldTypes.set(f.key, type);\n const note=f.ok?(f.readonly?' · read only':(f.saved?' · saved override · reference kept':'')):' · unavailable';\n const valueNote=f.saved?`Reference: ${esc(originalValue)} → saved: ${esc(displayValue)} `:'';\n const originalAttr=esc(originalValue);\n const input=type==='bool'\n ? `${displayValue==='1'?'On':'Off'} `\n : ` `;\n return `${head}${esc(f.label)} ${esc(f.key)}${note} ${valueNote} ${input} `;\n }).join('');\n box.innerHTML=`${html}
`;\n if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;\n updateRtConfigDirty();\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n async function saveRtConfig(){\n const values=collectRtConfigChanges();\n const clear_keys=collectRtConfigClearKeys();\n clear_keys.forEach(key=>{\n const input=document.querySelector(`.rt-config-input[data-key=\"${CSS.escape(key)}\"]`);\n if(input) values[key]=rtConfigOriginalValue(input);\n });\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});\n toastMessage('toast.rtorrentConfigSaved','success',{updated:j.result?.updated?.length});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function resetRtConfig(){\n // Note: Reset clears only saved UI overrides, then reloads the live state from rTorrent.\n if(!confirm('Clear all saved rTorrent UI overrides and reload current rTorrent values?')) return;\n setBusy(true);\n try{\n const j=await post('/api/rtorrent-config/reset',{});\n toastMessage('toast.rtorrentConfigReset','success',{removed:j.config?.reset_removed});\n await loadRtConfig();\n }catch(e){\n toast(e.message,'danger');\n } finally{\n setBusy(false);\n }\n }\n async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } }\n\n function bootstrapThemeUrl(theme){ /* Note: Themes use the URL map generated by the backend, so they also work offline. */ const key=theme||\"default\"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || \"\"; }\n function applyBootstrapTheme(theme){ bootstrapTheme = theme || \"default\"; const link=$(\"bootstrapThemeStylesheet\"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($(\"bootstrapThemeSelect\")) $(\"bootstrapThemeSelect\").value = bootstrapTheme; }\n function applyFontFamily(font){ fontFamily = font || \"default\"; document.documentElement.dataset.appFont = fontFamily; if($(\"fontFamilySelect\")) $(\"fontFamilySelect\").value = fontFamily; }\n function clampInterfaceScale(value){ value = Number(value || 100); if(!Number.isFinite(value)) value = 100; return Math.max(80, Math.min(140, Math.round(value / 5) * 5)); }\n function applyInterfaceScale(value){ interfaceScale = clampInterfaceScale(value); document.documentElement.style.setProperty(\"--ui-scale\", String(interfaceScale / 100)); if($(\"interfaceScaleRange\")) $(\"interfaceScaleRange\").value = interfaceScale; if($(\"interfaceScaleValue\")) $(\"interfaceScaleValue\").textContent = `${interfaceScale}%`; scheduleRender(false); }\n async function saveAppearancePreferences(){ applyBootstrapTheme($(\"bootstrapThemeSelect\")?.value || \"default\"); applyFontFamily($(\"fontFamilySelect\")?.value || \"default\"); applyInterfaceScale($(\"interfaceScaleRange\")?.value || interfaceScale); try{ await post(\"/api/preferences\",{bootstrap_theme:bootstrapTheme,font_family:fontFamily,interface_scale:interfaceScale}); toast(\"Appearance preferences saved\",\"success\"); }catch(e){ toast(e.message,\"danger\"); } }\n if($(\"titleSpeedEnabled\")) $(\"titleSpeedEnabled\").checked=titleSpeedEnabled;\n\n function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n\n function automationCondition(){\n const type=$('autoConditionType')?.value||'completed';\n const cond={type, negate:!!$('autoCondNegate')?.checked};\n if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }\n if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);\n // Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.\n if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);\n if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';\n if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';\n if(type==='path_contains') cond.text=$('autoCondText')?.value||'';\n return cond;\n }\n\n function automationEffect(){\n const type=$('autoEffectType')?.value||'add_label';\n const eff={type};\n if(type==='move'){\n eff.path=$('autoEffectPath')?.value||'';\n eff.move_data=!!$('autoMoveData')?.checked;\n eff.recheck=!!$('autoMoveRecheck')?.checked;\n eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;\n }\n if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';\n if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';\n return eff;\n }\n\n function updateAutomationForm(){\n const ct=$('autoConditionType')?.value||'';\n document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));\n const et=$('autoEffectType')?.value||'';\n document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));\n }\n\n function conditionText(c={}){\n const base=c.type==='no_seeds'?`seeds <= ${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`:c.type==='progress_gte'?`progress >= ${c.progress||0}%`:c.type==='progress_lte'?`progress <= ${c.progress||0}%`:c.type==='label_missing'?`missing label ${c.label||''}`:c.type==='label_has'?`has label ${c.label||''}`:c.type==='status'?`status = ${c.status||''}`:c.type==='path_contains'?`path contains ${c.text||''}`:'completed';\n return c.negate?`NOT (${base})`:base;\n }\n function effectText(e={}){\n if(e.type==='move'){\n const flags=[];\n if(e.move_data) flags.push('move data');\n if(e.recheck) flags.push('recheck');\n if(e.keep_seeding) flags.push('keep seeding');\n return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;\n }\n return e.type==='add_label'?`add label ${e.label||''}`:e.type==='remove_label'?`remove label ${e.label||''}`:e.type==='set_labels'?`set labels ${e.labels||''}`:e.type;\n }\n function ruleSummary(r){\n const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';\n const es=(r.effects||[]).map(effectText).join(' → ')||'no actions';\n return `${cs} → ${es}`;\n }\n\n function renderAutomationBuilder(){\n const cBox=$('automationConditionList');\n if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`IF ${esc(conditionText(c))} `).join(''):'No conditions added yet. ';\n const eBox=$('automationEffectList');\n if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`${i+1} ${esc(effectText(e))} `).join(''):'No actions added yet. ';\n }\n function resetAutomationForm(){\n if($('autoEditId')) $('autoEditId').value='';\n if($('autoName')) $('autoName').value='';\n if($('autoEnabled')) $('autoEnabled').checked=true;\n if($('autoCooldown')) $('autoCooldown').value='60';\n automationConditions=[]; automationEffects=[];\n $('automationCancelEditBtn')?.classList.add('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Save rule';\n renderAutomationBuilder(); updateAutomationForm();\n }\n function editAutomationRule(rule){\n if(!rule) return;\n if($('autoEditId')) $('autoEditId').value=rule.id||'';\n if($('autoName')) $('autoName').value=rule.name||'';\n if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;\n if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;\n automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];\n automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];\n $('automationCancelEditBtn')?.classList.remove('d-none');\n if($('automationSaveBtn')) $('automationSaveBtn').innerHTML=' Update rule';\n renderAutomationBuilder();\n }\n\n function summarizeActionObject(a={}){\n if(a.error) return `${esc(a.error)} `;\n const count=a.count || a.result?.count || a.result?.results?.length || '';\n const parts=[];\n if(a.type) parts.push(a.type);\n if(count) parts.push(`${count} torrent(s)`);\n if(a.path) parts.push(a.path);\n if(a.label) parts.push(`label ${a.label}`);\n if(a.labels) parts.push(`labels ${a.labels}`);\n if(a.move_data) parts.push('move data');\n if(a.recheck) parts.push('recheck');\n if(a.keep_seeding) parts.push('keep seeding');\n return `${esc(parts.join(' · ')||'action')} `;\n }\n function automationHistoryActions(raw){\n let actions=[];\n try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `${esc(raw||'')}
`; }\n if(!Array.isArray(actions)) actions=[actions];\n const summary=actions.map(summarizeActionObject).join(' ');\n const details=esc(JSON.stringify(actions,null,2));\n // Note: Large automation payloads are collapsed so JSON never stretches the modal width.\n return `${summary||'No actions'} ${details} `;\n }\n\n function renderAutomationHistory(hist=[]){\n if(!$('automationHistory')) return;\n const toolbar=' Clear history
';\n const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);\n // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.\n const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'No automation history yet.
';\n $('automationHistory').innerHTML=toolbar+body;\n }\n\n async function clearAutomationHistory(){\n if(!confirm('Clear automation history?')) return;\n setBusy(true);\n try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toastMessage('toast.automationLogsDeleted','success',{deleted:j.deleted}); renderAutomationHistory(j.history||[]); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function exportAutomations(){\n try{ const j=await (await fetch('/api/automations/export')).json(); if(!j.ok) throw new Error(j.error||'Automation export failed'); downloadJson(`pytorrent-automation-rules-${new Date().toISOString().slice(0,10)}.json`, j.export||j); toast(`Exported ${j.count||0} automation rule(s)`,'success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n\n async function importAutomations(file){\n if(!file) return;\n try{ const payload=JSON.parse(await file.text()); const j=await post('/api/automations/import',payload); toast(`Imported ${j.imported||0} automation rule(s)`,'success'); await loadAutomations(); }\n catch(e){ toast(e.message||'Automation import failed','danger'); }\n finally{ if($('automationImportFile')) $('automationImportFile').value=''; }\n }\n\n async function loadAutomations(){\n const j=await fetch('/api/automations').then(r=>r.json());\n const rules=j.rules||[], hist=j.history||[];\n automationRulesCache=rules;\n if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{\n const enabled=!!r.enabled;\n const toggleTitle=enabled?'Disable automation':'Enable automation';\n const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';\n const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';\n return `${esc(r.name)} ${enabled?'on ':'off '}
${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min
Remove
`;\n }).join(''):'No automation rules.
';\n renderAutomationHistory(hist);\n }\n\n async function toggleAutomationRule(rule){\n if(!rule) return;\n const payload={...rule, enabled:!rule.enabled};\n // Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }\n catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n async function saveAutomation(){\n const currentCond=automationCondition();\n const currentEff=automationEffect();\n const conditions=automationConditions.length?automationConditions:[currentCond];\n const effects=automationEffects.length?automationEffects:[currentEff];\n const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};\n setBusy(true);\n try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }\n catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n\n\n function cleanupCountCard(label, value, note=''){\n return `${esc(label)} ${esc(value ?? 0)} ${note?`${esc(note)} `:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n box.innerHTML=`${cards.join('')}
Profile cache Clears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Clear profile cacheLogs and history Pending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
Clear job logs Clear Smart Queue logs Clear operation logs Clear Planner logs Clear automation logs Clear all logs
Refresh
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n\n function diagCard(label,value,extra=''){ return `${esc(label)} ${esc(value ?? '-')}
`; }\n\n // Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.\n function applyFooterPreferences(){\n document.querySelectorAll('[data-footer-item]').forEach(el=>{\n const key=el.dataset.footerItem;\n el.classList.toggle('footer-pref-hidden', footerItems[key] === false);\n });\n }\n function renderFooterPreferences(){\n const box=$('footerPreferences');\n if(!box) return;\n box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>``).join('');\n }\n async function saveFooterPreferences(){\n document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });\n applyFooterPreferences();\n renderFooterPreferences();\n try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function compactSpeedText(value){\n // Note: The footer has limited space, so it removes spaces only from speed labels.\n return String(value || '0 B/s').replace(/\\s+(?=[KMGT]?i?B\\/s$|B\\/s$)/, '');\n }\n function speedPairText(down, up){\n // Note: Consistent DL/UL pair formatting is used in the footer and diagnostics.\n return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;\n }\n function peakDateText(value){\n // Note: Shortens the ISO timestamp from the database into a readable tooltip label.\n return value ? String(value).replace('T',' ').replace(/\\+00:00$/, ' UTC') : '-';\n }\n function updateSpeedPeaks(peaks={}){\n // Note: Shows the session and all-time record next to current speeds in the footer.\n const session=peaks.session||{};\n const allTime=peaks.all_time||{};\n const sessionText=speedPairText(session.down_h, session.up_h);\n const allTimeText=speedPairText(allTime.down_h, allTime.up_h);\n if($('statPeakSession')) $('statPeakSession').textContent=sessionText;\n if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;\n const box=$('statusSpeedPeaks');\n if(box){\n box.title=`Peak speed DL/UL\\nSession: ${sessionText}\\nSession DL at: ${peakDateText(session.down_at)}\\nSession UL at: ${peakDateText(session.up_at)}\\nAll-time: ${allTimeText}\\nAll-time DL at: ${peakDateText(allTime.down_at)}\\nAll-time UL at: ${peakDateText(allTime.up_at)}`;\n }\n }\n function browserSpeedSnapshot(){\n // Note: Browser title speed can fall back to the live torrent snapshot when system_stats is delayed or reports zero.\n let down=0, up=0;\n torrents.forEach(t=>{\n down += Number(t.down_rate || 0);\n up += Number(t.up_rate || 0);\n });\n return {down, up, down_h: humanRateLabel(down), up_h: humanRateLabel(up)};\n }\n function humanRateLabel(value){\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n let n=Math.max(0, Number(value || 0));\n let i=0;\n while(n>=1024 && i=10 || i===0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;\n }\n function numericSpeed(value){\n // Note: Accepts both raw bytes/s and human labels, so zero checks work for \"0\", \"0 B/s\" and \"0.0 KiB/s\".\n if(typeof value === 'number') return Math.max(0, value);\n const text=String(value ?? '').trim();\n if(!text) return 0;\n const match=text.match(/^([0-9]+(?:\\.[0-9]+)?)\\s*(B\\/s|KiB\\/s|MiB\\/s|GiB\\/s|TiB\\/s)?$/i);\n if(!match) return 0;\n const units=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];\n const unit=(match[2] || 'B/s').replace(/kib/i,'KiB').replace(/mib/i,'MiB').replace(/gib/i,'GiB').replace(/tib/i,'TiB').replace(/b\\/s/i,'B/s');\n return Number(match[1] || 0) * Math.pow(1024, Math.max(0, units.indexOf(unit)));\n }\n function applyLiveSpeedStats(stats={}){\n // Note: Fast-poller speed updates drive the tab title and peak speed UI without waiting for system_stats.\n const downRaw=Number(stats.down_rate || 0);\n const upRaw=Number(stats.up_rate || 0);\n const downH=stats.down_rate_h || humanRateLabel(downRaw);\n const upH=stats.up_rate_h || humanRateLabel(upRaw);\n if($('statDl')) $('statDl').textContent=downH || '0 B/s';\n if($('statUl')) $('statUl').textContent=upH || '0 B/s';\n if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=downH || '0 B/s';\n if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=upH || '0 B/s';\n if(stats.speed_peaks) updateSpeedPeaks(stats.speed_peaks);\n updateBrowserSpeedTitle(downH, upH, downRaw, upRaw);\n }\n function updateBrowserSpeedTitle(downH, upH, downRaw=null, upRaw=null){\n // Note: Keeps the browser tab title accurate even when system_stats is delayed or reports a stale zero.\n const fallback=browserSpeedSnapshot();\n const downValue=downRaw == null ? numericSpeed(downH) : Number(downRaw || 0);\n const upValue=upRaw == null ? numericSpeed(upH) : Number(upRaw || 0);\n const useFallbackDown=(downH == null || (downValue <= 0 && fallback.down>0));\n const useFallbackUp=(upH == null || (upValue <= 0 && fallback.up>0));\n lastBrowserSpeed.down=useFallbackDown ? fallback.down_h : (downH || '0 B/s');\n lastBrowserSpeed.up=useFallbackUp ? fallback.up_h : (upH || '0 B/s');\n const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;\n document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;\n try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}\n }\n async function saveTitleSpeedPreference(){\n // Note: The change applies immediately and is saved as a user preference.\n titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;\n updateBrowserSpeedTitle();\n try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n async function saveTrackerFaviconsPreference(){\n // Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.\n trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;\n renderTrackerFilters();\n try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }\n catch(e){ toast(e.message,'danger'); }\n }\n function updateFooterClock(){\n const el=$('statClock');\n if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});\n }\n function updateSocketStatus(s={}){\n const el=$('statSockets');\n if(!el) return;\n const open=s.open_sockets;\n const max=s.max_open_sockets;\n el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);\n const box=$('statusSockets');\n if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;\n }\n\n function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }\n function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }\n function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }\n function portStatusBadge(data={},attrs='',withPort=false){ const st=portStatusLabel(data.status); const active=data.open_port||data.port; const port=active?String(active):'-'; const label=withPort?`Port ${port} ${st}`:st; return ` ${esc(label)} `; }\n function portCheckedAt(data={}){ if(data.checked_at) return String(data.checked_at).replace('T',' ').replace(/\\+00:00$/,' UTC'); if(data.checked_at_epoch) return new Date(Number(data.checked_at_epoch)*1000).toLocaleString(); return ''; }\n function portCheckDetails(data={}){ const bits=[]; if(data.open_port) bits.push(`Open port: ${data.open_port}`); else if(data.port) bits.push(`First port: ${data.port}`); if(Array.isArray(data.ports)&&data.ports.length>1) bits.push(`Candidates: ${data.ports.join(', ')}`); if(Array.isArray(data.checked_ports)&&data.checked_ports.length) bits.push(`Checked: ${data.checked_ports.join(', ')}`); if(data.ports_truncated) bits.push('Port list truncated to safety limit'); if(data.public_ip) bits.push(`Public IP: ${data.public_ip}`); if(data.remote) bits.push('Remote profile'); if(data.source) bits.push(`Source: ${data.source}`); const checked=portCheckedAt(data); if(checked) bits.push(`Last check: ${checked}`); if(data.cached) bits.push('Cached result'); if(data.error) bits.push(data.error); if(data.fallback_error) bits.push(data.fallback_error); return bits; }\n function renderPortCheck(data={}){\n if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;\n const details=portCheckDetails(data);\n const title=details.join(' · ') || 'Port check disabled';\n if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id=\"portCheckBadge\" ');\n if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' · ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';\n if($('statusPortCheck')){\n $('statusPortCheck').classList.toggle('d-none', !data.enabled);\n $('statusPortCheck').title=title;\n }\n if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id=\"statusPortCheckBadge\" ',true);\n }\n async function loadPreferences(){\n try{\n const j=await (await fetch(`/api/preferences?_=${Date.now()}`, {cache:'no-store'})).json();\n const prefs=j.preferences||{};\n portCheckEnabled=!!Number(prefs.port_check_enabled ?? portCheckEnabled);\n automationToastsEnabled=Number(prefs.automation_toasts_enabled ?? (automationToastsEnabled?1:0))!==0;\n smartQueueToastsEnabled=Number(prefs.smart_queue_toasts_enabled ?? (smartQueueToastsEnabled?1:0))!==0;\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ diskMonitorPaths=[]; }\n bootstrapTheme=prefs.bootstrap_theme||bootstrapTheme;\n fontFamily=prefs.font_family||fontFamily;\n interfaceScale=Number(prefs.interface_scale||interfaceScale||100);\n try{ footerItems={...DEFAULT_FOOTER_ITEMS,...JSON.parse(prefs.footer_items_json||'{}')}; }catch(_){ footerItems={...DEFAULT_FOOTER_ITEMS}; }\n }catch(e){ console.warn('Preference load failed', e); }\n if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; if($('automationToastsEnabled')) $('automationToastsEnabled').checked=automationToastsEnabled; if($('smartQueueToastsEnabled')) $('smartQueueToastsEnabled').checked=smartQueueToastsEnabled; if($('diskMonitorMode')) $('diskMonitorMode').value=diskMonitorMode; if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').value=diskMonitorSelectedPath; renderDiskMonitorPaths(); applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); applyInterfaceScale(interfaceScale); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }\n function updateDiskMonitorUi(){\n // Note: Disk monitor radio switches are mirrored into the shared diskMonitorMode state.\n const mode=['default','selected','aggregate'].includes(diskMonitorMode)?diskMonitorMode:'default';\n if($('diskMonitorMode')) $('diskMonitorMode').value=mode;\n document.querySelectorAll('.disk-monitor-mode').forEach(input=>{ input.checked=input.value===mode; });\n const selectedDisabled=mode!=='selected' || !diskMonitorPaths.length;\n if($('diskMonitorSelectedPath')) $('diskMonitorSelectedPath').disabled=selectedDisabled;\n document.querySelectorAll('.disk-path-select').forEach(btn=>{ btn.disabled=mode==='aggregate'; btn.classList.toggle('active', btn.dataset.path===diskMonitorSelectedPath && mode==='selected'); });\n const hint=$('diskMonitorSelectedHint');\n if(hint){\n hint.textContent=mode==='aggregate' ? 'Aggregate mode uses all monitored paths, so one-path selection is locked.' : mode==='default' ? 'Default mode uses the rTorrent path, custom selection is optional.' : diskMonitorPaths.length ? 'This path drives the footer progress bar.' : 'Add at least one monitored path to use selected mode.';\n }\n }\n function renderDiskMonitorPaths(){\n const select=$('diskMonitorSelectedPath');\n if(select){\n const fallback=diskMonitorPaths.length?'Choose monitored path':'No custom paths yet';\n select.innerHTML=`${fallback} `+diskMonitorPaths.map(p=>`${esc(p)} `).join('');\n select.value=diskMonitorSelectedPath||'';\n }\n const box=$('diskMonitorPaths');\n if(box){\n box.innerHTML=diskMonitorPaths.length?diskMonitorPaths.map(p=>`${esc(p)} ${p===diskMonitorSelectedPath?'Selected for footer progress':'Used in aggregate tooltip and available for selected mode'}
Use
`).join(''):'No extra disk paths. Add a path above to monitor another storage directory.
';\n }\n updateDiskMonitorUi();\n }\n async function saveNotificationPrefs(){ automationToastsEnabled=!!$('automationToastsEnabled')?.checked; smartQueueToastsEnabled=!!$('smartQueueToastsEnabled')?.checked; try{ await post('/api/preferences',{automation_toasts_enabled:automationToastsEnabled,smart_queue_toasts_enabled:smartQueueToastsEnabled}); toast('Notification preferences saved','success'); }catch(e){ toast(e.message,'danger'); } }\n async function saveDiskMonitorPrefs(){\n // Note: Disk monitor mode is controlled by radio switches, so keep the in-memory mode instead of reading a removed select.\n const checkedMode=document.querySelector('.disk-monitor-mode:checked')?.value;\n diskMonitorMode=['default','selected','aggregate'].includes(checkedMode) ? checkedMode : (['default','selected','aggregate'].includes(diskMonitorMode) ? diskMonitorMode : 'default');\n diskMonitorSelectedPath=$('diskMonitorSelectedPath')?.value||diskMonitorSelectedPath||'';\n try{\n const res=await post('/api/preferences',{disk_monitor_paths_json:diskMonitorPaths,disk_monitor_mode:diskMonitorMode,disk_monitor_selected_path:diskMonitorSelectedPath});\n const prefs=res.preferences||{};\n // Note: Sync saved values back from the API so the footer uses the persisted disk source, not a stale UI guess.\n diskMonitorMode=prefs.disk_monitor_mode||diskMonitorMode;\n diskMonitorSelectedPath=prefs.disk_monitor_selected_path||diskMonitorSelectedPath||'';\n try{ diskMonitorPaths=JSON.parse(prefs.disk_monitor_paths_json||'[]'); }catch(_){ }\n renderDiskMonitorPaths();\n await refreshUserDiskUsage(true);\n toast('Disk monitor saved','success');\n }catch(e){ toast(e.message,'danger'); }\n }\n async function savePortCheckPref(){ portCheckEnabled=!!$('portCheckEnabled')?.checked; try{ await post('/api/preferences',{port_check_enabled:portCheckEnabled}); toast('Preferences saved','success'); await loadPortCheck(false); }catch(e){ toast(e.message,'danger'); } }\n async function loadPortCheck(force=false){ try{ const res=force?await post('/api/port-check',{}):await (await fetch('/api/port-check')).json(); if(!res.ok) throw new Error(res.error||'Port check failed'); renderPortCheck(res.port_check||{}); }catch(e){ renderPortCheck({status:'error',enabled:portCheckEnabled,error:e.message}); } }\n async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};\n const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const panes=[\n ['process','Process', diagnosticsSection('pyTorrent process', [diagCard('PID', py.pid), diagCard('Uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Python', py.python||'-')])],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', [diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')])],\n ['poller','Poller', diagnosticsSection('Adaptive poller', [diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)])],\n ['planner','Planner', diagnosticsSection('Planner', [diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')])],\n ['storage','Storage / jobs', diagnosticsSection('Database and cleanup', [diagCard('DB size', db.size_h||'-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')])],\n ['network','Network / speed', diagnosticsSection('Port and speed', [diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h))])],\n ['smart','Smart Queue', ` Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)} `]\n ];\n const tabs=`${panes.map((p,i)=>`${p[1]} `).join('')} `;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`${p[2]}
`).join('')}${scgi.error?`${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';\n function torrentStatsCard(label, value, note=''){\n return `${esc(label)} ${esc(value ?? '-')} ${note?`${esc(note)} `:''}
`;\n }\n function activeTorrentStatsPane(){\n const value=localStorage.getItem(TORRENT_STATS_PANE_STORAGE_KEY)||'overview';\n return ['overview','storage','sources','speed','cache'].includes(value) ? value : 'overview';\n }\n function setTorrentStatsPane(pane){\n const box=$('torrentStatsManager');\n if(!box) return;\n localStorage.setItem(TORRENT_STATS_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-torrentstats-pane]').forEach(x=>x.classList.toggle('active',x.dataset.torrentstatsPane===pane));\n box.querySelectorAll('[data-torrentstats-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.torrentstatsPanel!==pane));\n }\n function renderTorrentStats(stats={}){\n const box=$('torrentStatsManager');\n if(!box) return;\n const age=Number(stats.age_seconds||0);\n const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\\+00:00$/,' UTC') : '-';\n const active=activeTorrentStatsPane();\n const panes=[\n ['overview','Overview', [\n torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),\n torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')\n ]],\n ['storage','Storage', [\n torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),\n torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`)\n ]],\n ['sources','Seeds / peers', [\n torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample')\n ]],\n ['speed','Speed', [\n torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`)\n ]],\n ['cache','Cache', [\n torrentStatsCard('Updated', updated),\n torrentStatsCard('Age', `${age}s`)\n ]]\n ];\n if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;\n const errors=Array.isArray(stats.errors)&&stats.errors.length ? `File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : '';\n box.innerHTML=`${panes.map(p=>`${p[1]} `).join('')} ${panes.map(p=>``).join('')}${errors}`;\n }\n async function loadTorrentStats(force=false){\n const box=$('torrentStatsManager');\n if(!box) return;\n box.innerHTML=' Loading torrent statistics...';\n try{\n const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();\n if(!j.ok) throw new Error(j.error||'Torrent statistics failed');\n renderTorrentStats(j.stats||{});\n if(force) toast('Torrent statistics refreshed','success');\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n\n\n function addToolTab(tool, icon, label, beforeTool='appstatus'){\n if(document.querySelector(`.tool-tab[data-tool=\"${tool}\"]`)) return;\n const nav=document.querySelector('#toolsModal .nav.nav-pills');\n if(!nav) return;\n const li=document.createElement('li');\n li.className='nav-item';\n li.innerHTML=` ${label} `;\n const before=document.querySelector(`#toolsModal .tool-tab[data-tool=\"${beforeTool}\"]`)?.closest('.nav-item');\n nav.insertBefore(li,before||null);\n li.querySelector('.tool-tab')?.addEventListener('click',()=>activateToolTab(tool));\n }\n function inlineSwitch(id,label='Enable',extraClass=''){\n return `${label} `;\n }\n function plannerToggleRow(id,title,description){\n return `${title} ${description}
${inlineSwitch(id)}
`;\n }\n function plannerSpeedCard(prefix,title,sub){\n return ``;\n }\n";
diff --git a/pytorrent/static/js/state.js b/pytorrent/static/js/state.js
index ce66c6e..9d75f8f 100644
--- a/pytorrent/static/js/state.js
+++ b/pytorrent/static/js/state.js
@@ -1 +1 @@
-export const stateSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n const ROW_HEIGHT = 32, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Added\"},\n {key:\"created\", dir:1, label:\"Added\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Added\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_KEY = \"pytorrent.footerStatus.v1\";\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n const hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.textContent=`×${existing.count}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)} ×1 `;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?` Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)} `; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `No rTorrent profile configured. Add the first rTorrent profile to start loading torrents. Add rTorrent profile
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)} ` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers. ';\n return `rTorrent is starting or not responding yet. Waiting for torrent data from the active profile. ${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)} `;\n const list=$('mobileList');\n if(list) list.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))} `; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)} `; }\n function compactCell(value, max=120){ const text=String(value||\"\"); if(!text) return \"\"; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}…${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)} `; }\n function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return ``; }\n function progress(t){ return progressBar(t.progress); }\n";
+export const stateSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n const ROW_HEIGHT = 32, OVERSCAN = 14;\n const torrents = new Map();\n const browserViewPrefs = (()=>{ try{return JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};}catch(e){return {};} })();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Added\"},\n {key:\"created\", dir:1, label:\"Added\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n const COLUMN_DEFS = [[\"status\",\"Status\",false],[\"size\",\"Size\",false],[\"progress\",\"Progressbar\",false],[\"down_rate\",\"DL\",false],[\"up_rate\",\"UL\",false],[\"eta\",\"ETA\",false],[\"seeds\",\"Seeds\",false],[\"peers\",\"Peers\",false],[\"ratio\",\"Ratio\",false],[\"path\",\"Path\",false],[\"label\",\"Label\",false],[\"ratio_group\",\"Ratio group\",false],[\"down_total\",\"Downloaded\",true],[\"to_download\",\"To download\",true],[\"up_total\",\"Uploaded\",true],[\"created\",\"Added\",true],[\"priority\",\"Priority\",true],[\"state\",\"State\",true],[\"active\",\"Active\",true],[\"complete\",\"Complete\",true],[\"hashing\",\"Hashing\",true],[\"message\",\"Message\",true],[\"hash\",\"Hash\",true]];\n const DEFAULT_HIDDEN_COLUMNS = new Set(COLUMN_DEFS.filter(([, , hiddenByDefault]) => hiddenByDefault).map(([key]) => key));\n const savedColumns = window.PYTORRENT?.tableColumns || {};\n const DEFAULT_COLUMN_WIDTHS = {\n select: 34, name: 360, status: 110, size: 90, progress: 120,\n down_rate: 86, up_rate: 86, eta: 92, seeds: 70, peers: 70,\n ratio: 72, path: 300, label: 140, ratio_group: 130,\n down_total: 120, to_download: 120, up_total: 120, created: 150,\n priority: 80, state: 70, active: 70, complete: 82, hashing: 82,\n message: 220, hash: 280\n };\n const COLUMN_WIDTH_MIN = 44;\n const COLUMN_WIDTH_MAX = 720;\n const explicitlyShownColumns = new Set(savedColumns.shown || []);\n let hiddenColumns = new Set([...(savedColumns.hidden || []), ...[...DEFAULT_HIDDEN_COLUMNS].filter(key => !explicitlyShownColumns.has(key))]);\n // Note: Column widths are persisted with the existing column preferences payload, so no database migration is needed.\n function normalizeColumnWidths(value={}){\n const allowed = new Set(['select', ...COLUMN_DEFS.map(([key]) => key)]);\n const normalized = {...DEFAULT_COLUMN_WIDTHS};\n Object.entries(value || {}).forEach(([key, width])=>{\n if(allowed.has(key)) normalized[key] = clampNumber(width, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, DEFAULT_COLUMN_WIDTHS[key] || 120);\n });\n return normalized;\n }\n let columnWidths = normalizeColumnWidths(savedColumns.widths || {});\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n function mobileSortStepId(step){ return `${step.key}:${step.dir}`; }\n function normalizeMobileSortFilters(value={}){\n const normalized = Object.fromEntries(MOBILE_SORT_STEPS.map(step => {\n const id = mobileSortStepId(step);\n return [id, DEFAULT_MOBILE_SORT_FILTER_IDS.has(id)];\n }));\n Object.entries(value || {}).forEach(([id, enabled]) => { if(id in normalized) normalized[id] = !!enabled; });\n return normalized;\n }\n let mobileSortFilters = normalizeMobileSortFilters(savedColumns.mobileSortFilters || {});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n const DEFAULT_MOBILE_COLUMNS = new Set([\"status\",\"progress\",\"down_rate\",\"up_rate\",\"eta\",\"seeds\",\"peers\",\"ratio\",\"path\"]);\n const MOBILE_COLUMN_DEFS = COLUMN_DEFS.map(([key,label]) => [key, label, DEFAULT_MOBILE_COLUMNS.has(key)]);\n function normalizeMobileColumns(value={}){\n const normalized = {...Object.fromEntries(MOBILE_COLUMN_DEFS.map(([key,,shown])=>[key, shown]))};\n Object.entries(value || {}).forEach(([key, shown])=>{\n if(key === \"speed\"){ normalized.down_rate = !!shown; normalized.up_rate = !!shown; }\n else if(key === \"seed_peer\"){ normalized.seeds = !!shown; normalized.peers = !!shown; }\n else if(key in normalized) normalized[key] = !!shown;\n });\n return normalized;\n }\n let mobileColumns = normalizeMobileColumns(savedColumns.mobile || {});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n let mobileSmartFiltersEnabled = browserViewPrefs.mobileSmartFiltersEnabled ?? savedColumns.mobileSmartFiltersEnabled ?? true;\n let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_KEY = \"pytorrent.footerStatus.v1\";\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n const hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n function clampNumber(value, min, max, fallback){\n const num = Number(value);\n if(!Number.isFinite(num)) return fallback;\n return Math.max(min, Math.min(max, Math.round(num)));\n }\n function debounce(fn, delay=250){\n let timer = null;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), delay);\n };\n }\n function savePreferencePatch(payload, delay=350){\n const key = Object.keys(payload).sort().join('|');\n clearTimeout(preferenceSaveTimers.get(key));\n preferenceSaveTimers.set(key, setTimeout(async()=>{\n try{ await post('/api/preferences', payload); }catch(e){ console.warn('Preference save failed', e); }\n finally{ preferenceSaveTimers.delete(key); }\n }, delay));\n }\n function currentActiveFilterPreference(){\n return activeTrackerFilter ? `tracker:${activeTrackerFilter}` : activeFilter;\n }\n function saveTorrentSortPreference(){\n // Note: Sorting is persisted together with the current filter so mobile tracker scope cannot fall back to All trackers after a quick sort change.\n saveBrowserViewPrefs();\n savePreferencePatch({torrent_sort_json:{key:sortState.key, dir:sortState.dir}, active_filter:currentActiveFilterPreference()}, 200);\n }\n function saveBrowserViewPrefs(extra={}){\n try{\n const prev=JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n localStorage.setItem('pyTorrent.mobileViewPrefs', JSON.stringify({...prev, activeFilter:currentActiveFilterPreference(), mobileFilterKey:mobileActiveFilterKey, sortState, mobileColumns, columnWidths, ...extra}));\n }catch(e){}\n }\n function saveActiveFilterPreference(){\n saveBrowserViewPrefs();\n savePreferencePatch({active_filter:currentActiveFilterPreference()}, 250);\n }\n function cleanColumnPrefsHidden(values){ return [...values].filter(key => key !== \"progressbar\"); }\n async function resetViewPreferences(){\n activeFilter = \"all\";\n activeTrackerFilter = \"\";\n mobileActiveFilterKey = \"all\";\n sortState = {key:\"name\", dir:1};\n mobileColumns = normalizeMobileColumns();\n hiddenColumns = new Set(DEFAULT_HIDDEN_COLUMNS);\n columnWidths = normalizeColumnWidths();\n const height = applyDetailPanelHeight(255);\n renderColumnManager();\n document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter === 'all'));\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n try{\n await post('/api/preferences', {active_filter:\"all\", torrent_sort_json:{key:\"name\", dir:1}, detail_panel_height:height, table_columns_json:JSON.stringify({hidden:cleanColumnPrefsHidden(DEFAULT_HIDDEN_COLUMNS), shown:[], mobile:mobileColumns, mobileSmartFiltersEnabled:true, widths:columnWidths})});\n toast('View preferences reset','success');\n }catch(e){ toast(e.message,'danger'); }\n scheduleRender(true);\n }\n function applyDetailPanelHeight(height){\n const safeHeight = clampNumber(height, 160, 720, 255);\n document.documentElement.style.setProperty('--detail-panel-height', `${safeHeight}px`);\n const handle = $('detailResizeHandle');\n if(handle) handle.setAttribute('aria-valuenow', String(safeHeight));\n return safeHeight;\n }\n function saveDetailPanelHeight(height){\n const safeHeight = applyDetailPanelHeight(height);\n savePreferencePatch({detail_panel_height:safeHeight}, 250);\n }\n function setupDetailResizer(){\n const handle = $('detailResizeHandle');\n const content = document.querySelector('.content');\n if(!handle || !content) return;\n applyDetailPanelHeight(window.PYTORRENT?.detailPanelHeight || 255);\n let startY = 0, startHeight = 0;\n const onMove = (event) => {\n const pointerY = event.clientY ?? event.touches?.[0]?.clientY ?? startY;\n applyDetailPanelHeight(startHeight - (pointerY - startY));\n scheduleRender(false);\n };\n const onUp = () => {\n document.body.classList.remove('resizing-details');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n const value = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10);\n saveDetailPanelHeight(value);\n };\n handle.addEventListener('pointerdown', (event) => {\n event.preventDefault();\n startY = event.clientY;\n startHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--detail-panel-height'), 10) || 255;\n document.body.classList.add('resizing-details');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n }\n function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }\n function isAutomationEvent(msg){ return msg?.automation === true || msg?.source === 'automation'; }\n function shouldShowOperationToast(msg){\n // Note: Automation-created operation toasts follow the Automation toasts preference.\n return !isAutomationEvent(msg) || automationToastsEnabled;\n }\n function toast(msg, type=\"secondary\") {\n // Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.\n const h=$('toastHost');\n if(!h) return;\n const text=String(msg ?? '');\n const key=toastKey(text,type);\n const existing=toastGroups.get(key);\n if(existing){\n existing.count += 1;\n const badge=existing.el.querySelector('.toast-count');\n if(badge){ badge.textContent=`×${existing.count}`; badge.classList.remove('d-none'); }\n clearTimeout(existing.timer);\n existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);\n return;\n }\n const el=document.createElement('div');\n el.className=`toast-item text-bg-${type}`;\n el.innerHTML=`${esc(text)} ×1 `;\n h.appendChild(el);\n const entry={el,count:1,timer:null};\n entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);\n toastGroups.set(key,entry);\n }\n function setBusy(on, label='Working...'){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; const loader=$('globalLoader'); if(loader){ loader.classList.toggle('d-none', pendingBusy===0); const span=loader.querySelector('span:last-child'); if(span) span.textContent=label; } $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }\n function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }\n function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }\n function buttonBusy(btn,on){ if(!btn)return; btn.disabled=on; const label=btn.querySelector('.btn-label'); if(label){ if(!label.dataset.orig) label.dataset.orig=label.innerHTML; label.innerHTML=on?` Working...`:label.dataset.orig; }}\n function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }\n function loadingMarkup(label='Loading data...'){ return `${esc(label)}
`; }\n // Note: Keeps empty-state colspans aligned with the desktop torrent table column count.\n function torrentColumnSpan(){ return 25; }\n function loadingTableRow(label='Loading torrents...'){ return `${loadingMarkup(label)} `; }\n // Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.\n function renderNoProfileState(){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `No rTorrent profile configured. Add the first rTorrent profile to start loading torrents. Add rTorrent profile
`;\n }\n if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';\n }\n function clearRtorrentStartingState(){\n rtorrentStartingMessage='';\n rtorrentStartingSince=0;\n if(rtorrentStartingTimer){ clearTimeout(rtorrentStartingTimer); rtorrentStartingTimer=null; }\n }\n function rtorrentStartingHtml(error=''){\n const details=error ? `${esc(error)} ` : 'Port can already be open while XML-RPC/SCGI is still warming up. The list will load automatically after rTorrent answers. ';\n return `rTorrent is starting or not responding yet. Waiting for torrent data from the active profile. ${details}
`;\n }\n function scheduleRtorrentStartingState(error=''){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(!(hasTorrentSnapshot && torrents.size)){\n renderRtorrentStartingState(rtorrentStartingMessage, true);\n return;\n }\n if(!rtorrentStartingSince) rtorrentStartingSince = Date.now();\n if(rtorrentStartingTimer) return;\n rtorrentStartingTimer = setTimeout(() => {\n rtorrentStartingTimer = null;\n if(rtorrentStartingMessage) renderRtorrentStartingState(rtorrentStartingMessage, true);\n }, RTORRENT_STALE_GRACE_MS);\n }\n function renderRtorrentStartingState(error='', force=false){\n rtorrentStartingMessage = String(error || 'rTorrent is starting or not responding yet.');\n if(hasTorrentSnapshot && torrents.size && !force) return;\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body=$('torrentBody');\n if(body) body.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)} `;\n const list=$('mobileList');\n if(list) list.innerHTML = `${rtorrentStartingHtml(rtorrentStartingMessage)}
`;\n if($('detailPane')) $('detailPane').innerHTML = 'rTorrent is starting. Details will appear after the first successful response.';\n }\n function parseDate(value){ const raw=String(value||'').trim(); if(!raw) return null; const d=new Date(raw); return Number.isNaN(d.getTime()) ? null : {raw,d}; }\n function formatDate(value, mode='short'){\n const parsed=parseDate(value);\n if(!parsed) return String(value||'');\n const opts=mode==='full'\n ? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}\n : {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};\n return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');\n }\n function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `${esc(formatDate(value))} `; }\n // Note: Human-readable date cells keep full timestamps visible without squeezing table columns.\n function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `${esc(full)} `; }\n function compactCell(value, max=120){ const text=String(value||\"\"); if(!text) return \"\"; const short=text.length>max ? `${text.slice(0, Math.floor(max*0.62))}…${text.slice(-Math.floor(max*0.28))}` : text; return `${esc(short)} `; }\n function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return ``; }\n function progress(t){ return progressBar(t.progress); }\n";
diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css
index 9f1fa83..5924199 100644
--- a/pytorrent/static/styles.css
+++ b/pytorrent/static/styles.css
@@ -3338,6 +3338,21 @@ body.mobile-mode .mobile-filter-bar {
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.55rem;
}
+
+.column-config-section {
+ display: grid;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.column-config-section:last-child {
+ margin-bottom: 0;
+}
+
+.column-config-section h6 {
+ margin: 0;
+}
+
.mobile-progress:empty {
display: none;
}
@@ -4181,6 +4196,11 @@ body,
max-width: 260px;
}
+.operation-log-hide-jobs {
+ align-items: center;
+ min-height: 31px;
+}
+
.operation-log-settings-actions {
display: flex;
flex-wrap: wrap;
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html
index 646939d..4350a2e 100644
--- a/pytorrent/templates/index.html
+++ b/pytorrent/templates/index.html
@@ -264,7 +264,7 @@
-
+
{% if auth_enabled and current_user and current_user.role == 'admin' %}
{% endif %}
@@ -309,7 +309,7 @@
-
+