Files
pyTorrent/pytorrent/static/app.js
Mateusz Gruszczyński 8a33a8af24 queue lock stalled
2026-05-08 21:32:08 +02:00

1303 lines
138 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>'"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[c]));
const ROW_HEIGHT = 34, OVERSCAN = 14;
const torrents = new Map();
let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = "all";
let sortState = {key: "name", dir: 1}, renderPending = false, renderVersion = 0, lastRenderSignature = "";
let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = "/";
const traffic = [], systemUsage = [];
const socket = io({transports:["polling"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000});
const COLUMN_DEFS = [["status","Status"],["size","Size"],["progress","Progress"],["down_rate","DL"],["up_rate","UL"],["seeds","Seeds"],["peers","Peers"],["ratio","Ratio"],["path","Path"],["label","Label"],["ratio_group","Ratio group"]];
let hiddenColumns = new Set((window.PYTORRENT?.tableColumns?.hidden || []));
let knownLabels = [];
let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false;
let automationSmartQueueStats = null;
let peersRefreshTimer = null;
let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);
let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);
let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || "default";
let fontFamily = window.PYTORRENT?.fontFamily || "default";
let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);
let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);
let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};
let trackerSummaryStatus = 'idle';
let trackerSummarySignature = "";
let trackerSummaryTimer = null;
const BASE_TITLE = document.title || "pyTorrent";
const lastBrowserSpeed = {down: "0 B/s", up: "0 B/s"};
const FOOTER_ITEM_DEFS = [
["cpu", "CPU"], ["ram", "RAM"], ["usage_chart", "CPU/RAM chart"], ["disk", "Disk"],
["version", "rTorrent version"], ["speed_down", "Download speed"], ["speed_up", "Upload speed"],
["speed_peaks", "Peak speeds"], ["limits", "Speed limits"], ["totals", "Total transfer"], ["port_check", "Port check"],
["clock", "Clock"], ["sockets", "Open sockets"], ["shown", "Shown torrents"], ["selected", "Selected torrents"], ["docs", "API docs"]
];
let footerItems = {...Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, true])), ...(window.PYTORRENT?.footerItems || {})};
let modalLabels = new Set(), defaultDownloadPath = null;
let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;
let torrentSummary = null;
let profileCache = new Map();
const hasActiveProfile = !!window.PYTORRENT?.activeProfile;
let firstRunSetupShown = false;
const activeOperations = new Map();
// Note: Keeps live filter tooltips stable while the pointer is over a filter button.
const filterTooltipState = new WeakMap();
const toastGroups = new Map();
function toastKey(msg, type){ return `${type}::${String(msg ?? '')}`; }
function toast(msg, type="secondary") {
// Note: Groups identical toasts fired together, so repeated automation/action events do not flood the UI.
const h=$('toastHost');
if(!h) return;
const text=String(msg ?? '');
const key=toastKey(text,type);
const existing=toastGroups.get(key);
if(existing){
existing.count += 1;
const badge=existing.el.querySelector('.toast-count');
if(badge){ badge.textContent=`×${existing.count}`; badge.classList.remove('d-none'); }
clearTimeout(existing.timer);
existing.timer=setTimeout(()=>{ existing.el.remove(); toastGroups.delete(key); },3500);
return;
}
const el=document.createElement('div');
el.className=`toast-item text-bg-${type}`;
el.innerHTML=`<span class="toast-message">${esc(text)}</span><span class="toast-count d-none">×1</span>`;
h.appendChild(el);
const entry={el,count:1,timer:null};
entry.timer=setTimeout(()=>{ el.remove(); toastGroups.delete(key); },3500);
toastGroups.set(key,entry);
}
function setBusy(on){ pendingBusy += on ? 1 : -1; if(pendingBusy<0) pendingBusy=0; $('globalLoader')?.classList.toggle('d-none', pendingBusy===0); $('busyBadge')?.classList.toggle('d-none', pendingBusy===0); }
function setInitialLoader(title, text){ if(initialLoaderDone) return; if($('initialLoaderTitle') && title) $('initialLoaderTitle').textContent=title; if($('initialLoaderText') && text) $('initialLoaderText').textContent=text; }
function hideInitialLoader(){ if(initialLoaderDone) return; initialLoaderDone=true; $('initialLoader')?.classList.add('is-hidden'); }
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?`<span class="spinner-border spinner-border-sm me-1"></span>Working...`:label.dataset.orig; }}
function activeTab(){ return document.querySelector('#detailTabs .nav-link.active')?.dataset.tab || 'general'; }
function loadingMarkup(label='Loading data...'){ return `<div class="loading-line loading-center"><span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span>${esc(label)}</span></div>`; }
function loadingTableRow(label='Loading torrents...'){ return `<tr><td colspan="13" class="empty loading-cell">${loadingMarkup(label)}</td></tr>`; }
// Note: Handles fresh installations with no configured rTorrent profile, so the UI does not wait forever for a snapshot.
function renderNoProfileState(){
hasTorrentSnapshot = false;
torrentSummary = {filters:{all:{count:0},downloading:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};
torrents.clear();
selected.clear();
renderCounts();
const body = $('torrentBody');
if(body){
body.innerHTML = `<tr><td colspan="13" class="empty"><div class="empty-state"><b>No rTorrent profile configured.</b><span>Add the first rTorrent profile to start loading torrents.</span><button id="setupProfileBtn" class="btn btn-sm btn-primary" type="button"><i class="fa-solid fa-server"></i> Add rTorrent profile</button></div></td></tr>`;
}
if($('detailPane')) $('detailPane').innerHTML = 'Add rTorrent profile first.';
}
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}; }
function formatDate(value, mode='short'){
const parsed=parseDate(value);
if(!parsed) return String(value||'');
const opts=mode==='full'
? {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}
: {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'};
return new Intl.DateTimeFormat('pl-PL', opts).format(parsed.d).replace(',', '');
}
function dateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); return `<span class="date-compact" title="${esc(formatDate(value,'full'))}">${esc(formatDate(value))}</span>`; }
// Note: Human-readable date cells keep full timestamps visible without squeezing table columns.
function humanDateCell(value){ const parsed=parseDate(value); if(!parsed) return esc(value||''); const full=formatDate(value,'full'); return `<span class="date-readable" title="${esc(parsed.raw)}">${esc(full)}</span>`; }
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 `<span class="text-compact" title="${esc(text)}">${esc(short)}</span>`; }
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 `<div class="progress torrent-progress${done}${cls}" title="${esc(pct)}%"><div class="progress-bar" style="width:${pct}%;background:${bg}"></div><span>${esc(pct)}%</span></div>`; }
function progress(t){ return progressBar(t.progress); }
// Note: Displays status filter summaries calculated and cached by the backend API.
const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', stopped:'countStopped', moving:'countMoving'};
function formatFilterBytes(value){ return fmtBytes(value).replace(/\.0 (?=GiB|TiB)/, ' '); }
function filterMetaLine(bucket){
if(!bucket || !Number(bucket.count||0)) return '';
const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);
return `Data ${formatFilterBytes(disk)}`;
}
function filterNeedsDownloadDetails(type, bucket){
if(!bucket || !Number(bucket.count||0)) return false;
if(type==='downloading') return true;
if(type!=='paused' && type!=='stopped') return false;
const size=Number(bucket.size||0);
const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);
const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));
const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));
return size > 0 && remaining > 0 && progress < 100;
}
function filterTooltipLine(bucket, type){
if(!bucket || !Number(bucket.count||0)) return '';
const size=Number(bucket.size||0);
const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);
const completed=Number(bucket.completed_bytes ?? disk);
const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));
const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));
const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));
const lines=[`Data: ${formatFilterBytes(disk)}`];
if(filterNeedsDownloadDetails(type, bucket)){
lines.push(`Total to download: ${formatFilterBytes(size)}`);
lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);
lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);
}
return lines.join('\n');
}
function applyFilterTooltip(button, tooltip, ariaLabel){
if(tooltip){
button.title = tooltip;
button.setAttribute('aria-label', ariaLabel);
} else {
button.removeAttribute('title');
button.removeAttribute('aria-label');
}
}
function ensureStableFilterTooltip(button){
if(filterTooltipState.has(button)) return filterTooltipState.get(button);
const state = {hovering:false, pending:null};
filterTooltipState.set(button, state);
button.addEventListener('mouseenter', () => {
state.hovering = true;
state.pending = null;
});
button.addEventListener('mouseleave', () => {
state.hovering = false;
if(state.pending){
applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);
state.pending = null;
}
});
return state;
}
// Note: Freezes tooltip content during hover; the next hover receives the newest live summary.
function setStableFilterTooltip(button, tooltip, ariaLabel){
const state = ensureStableFilterTooltip(button);
if(state.hovering){
state.pending = {tooltip, ariaLabel};
return;
}
applyFilterTooltip(button, tooltip, ariaLabel);
}
function movingOperationRows(){
// Note: The Moving filter is based only on active move operations, not queued jobs.
return [...torrents.values()].filter(t=>{
const op=activeOperationFor(t);
return op?.action==='move' && op?.state==='running';
});
}
function movingFilterCount(){ return movingOperationRows().length; }
function setFilterSummary(type){
const el=$(FILTER_COUNT_IDS[type]);
if(!el) return;
const bucket=type==='moving' ? {count:movingFilterCount()} : (torrentSummary?.filters?.[type] || {count:0});
const meta=type==='moving' ? '' : filterMetaLine(bucket, type);
const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);
el.innerHTML=`<span class="filter-count">${esc(bucket.count||0)}</span>${meta?`<span class="filter-meta">${esc(meta)}</span>`:''}`;
const button=el.closest('.filter');
if(button){
const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\n/g, ', ')}` : '';
button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));
setStableFilterTooltip(button, tooltip, ariaLabel);
}
}
function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }
function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }
function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }
function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }
function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }
function torrentHasError(t){ return !!torrentWarning(t); }
function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }
function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && ![t.name,t.path,t.label,t.hash,t.ratio_group].join(' ').toLowerCase().includes(q)) return false; if(activeFilter==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return isChecking(t); if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state && !isChecking(t); if(activeFilter==='moving') { const op=activeOperationFor(t); return op?.action==='move' && op?.state==='running'; } if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('tracker:')) return rowHasTracker(t,activeFilter.slice(8)); return true; }
function compareRows(a,b){ const k=sortState.key; let av=a[k], bv=b[k]; if(typeof av==='string'||typeof bv==='string') return String(av||'').localeCompare(String(bv||''))*sortState.dir; return ((Number(av||0)>Number(bv||0))?1:(Number(av||0)<Number(bv||0)?-1:0))*sortState.dir; }
function sortIcon(key){ if(sortState.key!==key) return ''; return sortState.dir>0?" <i class='fa-solid fa-caret-up'></i>":" <i class='fa-solid fa-caret-down'></i>"; }
function updateSortHeaders(){ document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{ const base=th.dataset.baseText||th.textContent.trim(); th.dataset.baseText=base; th.innerHTML=`${esc(base)}${sortIcon(th.dataset.sort)}`; th.classList.toggle('sorted',sortState.key===th.dataset.sort); }); }
// Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.
function syncFilterButtons(){
// Note: The active class is synchronized after automatically returning from Moving to All.
document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter===activeFilter));
}
function renderCounts(){
// Note: When the last move operation finishes, the hidden filter does not leave an empty list active.
if(activeFilter==='moving' && !movingFilterCount()) activeFilter='all';
syncFilterButtons();
Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);
$('statSelected').textContent=selected.size;
}
function bindSidebarFilterClicks(root){
root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{
document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
activeFilter=b.dataset.filter;
if($('tableWrap')) $('tableWrap').scrollTop=0;
scheduleRender(true);
}));
}
function renderLabelFilters(){
const box=$('labelFilters');
if(!box) return;
const counts=new Map();
[...torrents.values()].forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));
const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));
if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))) activeFilter='all';
box.innerHTML=labels.length?`<div class="small text-muted px-2 mb-1">Labels</div>${labels.map(l=>`<button class="filter label-filter ${activeFilter==='label:'+l?'active':''}" data-filter="label:${esc(l)}"><span><i class="fa-solid fa-tag"></i> ${esc(l)}</span><span>${counts.get(l)}</span></button>`).join('')}`:'';
bindSidebarFilterClicks(box);
}
function trackerFavicon(tracker){
const domain=typeof tracker==='string'?tracker:(tracker?.domain||'');
if(!trackerFaviconsEnabled || !domain) return '<i class="fa-solid fa-bullseye"></i>';
// Note: Cached favicons are served from the static/tracker_favicons symlink; the API path is only a one-time cache warmer fallback.
const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}?refresh=1`;
const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback;
return `<img class="tracker-favicon" src="${esc(src)}" alt="" loading="lazy" data-fallback-src="${esc(fallback)}" onerror="if(this.dataset.retry!=='1'){this.dataset.retry='1';this.src=this.dataset.fallbackSrc;}else{this.classList.add('d-none')}"><i class="fa-solid fa-bullseye tracker-fallback-icon"></i>`;
}
function trackerFilterPlaceholder(){
if(trackerSummaryStatus==='loading') return '<div class="tracker-filter-empty"><span class="spinner-border spinner-border-xs"></span> Loading cached trackers...</div>';
if(trackerSummaryStatus==='error') return '<div class="tracker-filter-empty text-warning"><i class="fa-solid fa-triangle-exclamation"></i> Tracker list unavailable</div>';
if(Number(trackerSummary.pending||0)) return `<div class="tracker-filter-empty"><span class="spinner-border spinner-border-xs"></span> Tracker cache: ${esc(trackerSummary.cached||0)}/${esc(trackerSummary.scanned||0)}</div>`;
if(hasTorrentSnapshot && torrents.size) return '<div class="tracker-filter-empty">No trackers found</div>';
return '<div class="tracker-filter-empty">Waiting for torrents...</div>';
}
function renderTrackerFilters(){
const box=$('trackerFilters');
if(!box) return;
const trackers=trackerSummary.trackers || [];
if(activeFilter.startsWith('tracker:') && !trackers.some(t=>t.domain===activeFilter.slice(8))) activeFilter='all';
// Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.
const rows=trackers.length
? trackers.map(t=>`<button class="filter tracker-filter ${activeFilter==='tracker:'+t.domain?'active':''}" data-filter="tracker:${esc(t.domain)}"><span>${trackerFavicon(t)} ${esc(t.domain)}</span><span>${esc(t.count||0)}</span></button>`).join('')
: trackerFilterPlaceholder();
box.innerHTML=`<div class="small text-muted px-2 mb-1">Trackers</div>${rows}`;
bindSidebarFilterClicks(box);
}
async function refreshTrackerSummary(force=false){
const hashes=[...torrents.keys()].sort();
const sig=`${hashes.length}:${hashes[0]||''}:${hashes[hashes.length-1]||''}:${trackerFaviconsEnabled?1:0}`;
if(!force && sig===trackerSummarySignature && !Number(trackerSummary.pending||0)) return;
trackerSummarySignature=sig;
if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; }
trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'loading';
renderTrackerFilters();
try{
// Note: Nie wysyłamy 13k hashy w URL; backend bierze lokalny snapshot i doczytuje cache małymi porcjami.
const j=await (await fetch('/api/trackers/summary?scan_limit=0&warm=1&bg_limit=80')).json();
if(!j.ok && !j.summary) throw new Error(j.error||'Tracker summary failed');
trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0};
trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':Number(trackerSummary.pending||0)?'empty':'empty';
renderTrackerFilters();
scheduleRender(true);
if(Number(trackerSummary.pending||0)>0){
clearTimeout(trackerSummaryTimer);
trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(true).catch(()=>{}), 5000);
}
}catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); }
}
function scheduleTrackerSummary(force=false){
clearTimeout(trackerSummaryTimer);
trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(force).catch(()=>{}), force?50:600);
}
function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }
function applyColumnVisibility(){ document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col))); }
function actionLabel(action){
const labels={start:'Starting',pause:'Pausing',stop:'Stopping',resume:'Resuming',recheck:'Checking',reannounce:'Reannouncing',remove:'Removing',move:'Moving',set_label:'Setting label',set_ratio_group:'Setting ratio'};
return labels[action] || `Working: ${action}`;
}
function actionIcon(action){
return ({start:'fa-play',pause:'fa-pause',stop:'fa-stop',resume:'fa-play',recheck:'fa-rotate',reannounce:'fa-bullhorn',remove:'fa-trash',move:'fa-folder-open',set_label:'fa-tag',set_ratio_group:'fa-scale-balanced'}[action]) || 'fa-gears';
}
function markTorrentOperation(hashes, action, jobId, state='queued'){
const label=actionLabel(action);
[...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));
scheduleRender(true);
}
function markQueuedJobs(response, fallbackHashes, action){
// Note: Supports API responses that split one large user action into multiple queued bulk parts.
const jobs=Array.isArray(response?.jobs)?response.jobs:[];
if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; }
markTorrentOperation(fallbackHashes,action,response?.job_id,'queued');
}
function clearJobOperation(jobId, hashes=[]){
if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }
(hashes||[]).forEach(hash=>activeOperations.delete(hash));
scheduleRender(true);
}
function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }
function statusMeta(t){
const op=activeOperationFor(t);
if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};
const status=String(t.status||'').toLowerCase();
if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};
if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};
if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};
if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};
if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};
return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};
}
function statusBadge(t){ const m=statusMeta(t); return `<span class="badge status-badge ${m.cls}"><i class="fa-solid ${m.icon} me-1"></i>${esc(m.label || t.status)}</span>`; }
function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }
function torrentNameIcon(t){ const m=statusMeta(t); return `<i class="fa-solid ${m.icon} ${m.color}"></i>`; }
function renderRow(t){ const labels=labelNames(t.label).map(l=>`<span class="chip label-mini"><i class="fa-solid fa-tag"></i> ${esc(l)}</span>`).join(' '); const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\n'); return `<tr data-hash="${esc(t.hash)}" class="${classes}"><td data-col="select" class="sel"><input class="row-check" type="checkbox" ${selected.has(t.hash)?'checked':''}></td><td data-col="name" class="name" title="${esc(title)}">${warn?'<i class="fa-solid fa-triangle-exclamation torrent-warning-icon"></i> ':''}${torrentNameIcon(t)} ${esc(t.name)}</td><td data-col="status">${statusBadge(t)}</td><td data-col="size">${esc(t.size_h)}</td><td data-col="progress">${progress(t)}</td><td data-col="down_rate">${esc(t.down_rate_h)}</td><td data-col="up_rate">${esc(t.up_rate_h)}</td><td data-col="seeds">${esc(t.seeds)}</td><td data-col="peers">${esc(t.peers)}</td><td data-col="ratio">${esc(t.ratio)}</td><td data-col="path" class="path" title="${esc(t.path)}">${esc(t.path)}</td><td data-col="label">${labels||'<span class="text-muted">-</span>'}</td><td data-col="ratio_group">${esc(t.ratio_group||'')}</td></tr>`; }
function mobileFilterDefs(){ const arr=[...torrents.values()]; const f=torrentSummary?.filters||{}; const defs=[['all','All',f.all?.count??0],['downloading','Downloading',f.downloading?.count??0],['seeding','Seeding',f.seeding?.count??0],['paused','Paused',f.paused?.count??0],['checking','Checking',f.checking?.count??0],['error','With error',f.error?.count??0],['stopped','Stopped',f.stopped?.count??0]]; const movingCount=movingFilterCount(); if(movingCount) defs.push(['moving','Moving',movingCount]); const counts=new Map(); arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label'])); (trackerSummary.trackers||[]).forEach(t=>defs.push([`tracker:${t.domain}`,t.domain,t.count,'tracker'])); return defs; }
function renderMobileFilters(){
const bar=$('mobileFilterBar');
if(!bar) return;
const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash));
const someVisible=visibleRows.some(t=>selected.has(t.hash));
const opts=mobileFilterDefs().map(([key,label,count,type])=>`<option value="${esc(key)}" ${activeFilter===key?'selected':''}>${type==='label'?'Label: ':type==='tracker'?'Tracker: ':''}${esc(label)} (${count})</option>`).join('');
const bulk=selected.size?`<button id="mobileBulkLabel" class="btn btn-xs btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#labelModal"><i class="fa-solid fa-tag"></i> Label</button><button id="mobileBulkMove" class="btn btn-xs btn-outline-primary" type="button" data-action="move"><i class="fa-solid fa-folder-open"></i> Move</button>`:'';
// Note: Mobile bulk actions reuse the existing label modal and move picker, so desktop behavior stays unchanged.
bar.innerHTML=`<div class="mobile-filter-actions"><button id="mobileSelectAll" class="btn btn-xs ${allVisible?'btn-primary':'btn-outline-primary'}" type="button"><i class="fa-solid fa-check-double"></i> ${allVisible?'Unselect all':'Select all'}</button><button id="mobileClearSelection" class="btn btn-xs btn-outline-secondary" type="button" ${someVisible?'':'disabled'}><i class="fa-solid fa-xmark"></i> Clear</button>${bulk}<span>${selected.size} selected</span></div><div class="mobile-filter-select-row"><label for="mobileFilterSelect"><i class="fa-solid fa-filter"></i> Filter</label><select id="mobileFilterSelect" class="form-select form-select-sm">${opts}</select></div>`;
}
function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); return `<div class="mobile-card ${classes}" data-hash="${esc(t.hash)}" title="${esc(warn||op?.label||'')}"><div class="name">${warn?'<i class="fa-solid fa-triangle-exclamation torrent-warning-icon"></i> ':''}${torrentNameIcon(t)} ${esc(t.name)}</div><div class="small text-muted">${statusBadge(t)} · ${esc(t.progress)}% · Ratio ${esc(t.ratio)}</div><div class="small">DL ${esc(t.down_rate_h)} / UL ${esc(t.up_rate_h)}</div><div class="small text-truncate">${esc(t.path)}</div><div class="mobile-actions"><button class="btn btn-xs btn-outline-success" data-action="start" title="Start"><i class="fa-solid fa-play"></i></button><button class="btn btn-xs btn-outline-warning" data-action="pause" title="Pause"><i class="fa-solid fa-pause"></i></button><button class="btn btn-xs btn-outline-secondary" data-action="stop" title="Stop"><i class="fa-solid fa-stop"></i></button><button class="btn btn-xs btn-outline-primary" data-action="move" title="Move"><i class="fa-solid fa-folder-open"></i></button><button class="btn btn-xs btn-outline-primary" data-mobile-modal="label" title="Set label"><i class="fa-solid fa-tag"></i></button><button class="btn btn-xs btn-outline-info" data-action="recheck" title="Force recheck"><i class="fa-solid fa-rotate"></i></button><button class="btn btn-xs btn-outline-primary" data-action="reannounce" title="Reannounce"><i class="fa-solid fa-bullhorn"></i></button></div><div class="mobile-progress">${progress(t)}</div></div>`; }).join('') || (hasTorrentSnapshot ? `<div class="empty">No torrents.</div>` : loadingMarkup('Loading torrents...')); }
function renderTable(){ updateBulkBar(); renderCounts(); renderLabelFilters(); renderTrackerFilters(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?'<tr><td colspan="13" class="empty">No torrents for this filter.</td></tr>':loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/ROW_HEIGHT)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/ROW_HEIGHT)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*ROW_HEIGHT,bottom=Math.max(0,(visibleRows.length-end)*ROW_HEIGHT); body.innerHTML=(top?`<tr class="virtual-spacer"><td colspan="13" style="height:${top}px"></td></tr>`:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?`<tr class="virtual-spacer"><td colspan="13" style="height:${bottom}px"></td></tr>`:''); applyColumnVisibility(); }
function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }
function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }
function selectedHashes(){ return [...selected]; }
function updateBulkBar(){ const bar=$("bulkBar"); if(!bar) return; bar.classList.toggle("d-none", selected.size<=1); const c=$("bulkSelectedCount"); if(c) c.textContent=selected.size; }
function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }
async function post(url,data,method='POST'){ const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data||{})}); const json=await res.json(); if(!json.ok) throw new Error(json.error||'Operation failed'); return json; }
async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
function flag(iso){ const code=String(iso||'').toLowerCase(); return code?`<span class="fi fi-${esc(code)}"></span> <span>${esc(code.toUpperCase())}</span>`:'-'; }
function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `<table class="table table-sm detail-table${cls}"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }
function responsiveTable(headers,rows,extraClass=''){ return `<div class="responsive-table-wrap">${table(headers,rows,extraClass)}</div>`; }
function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }
function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>`<span class="chip label-mini"><i class="fa-solid fa-tag"></i> ${esc(l)}</span>`).join(' '):''; $('detailPane').innerHTML=t?`<div class="general-grid"><div><b>Name</b><span>${esc(t.name)}</span></div><div><b>Hash</b><span>${esc(t.hash)}</span></div><div><b>Path</b><span>${esc(t.path)}</span></div><div><b>Size</b><span>${esc(t.size_h)}</span></div><div><b>Progress</b><span>${esc(t.progress)}%</span></div><div><b>Ratio</b><span>${esc(t.ratio)}</span></div><div><b>Downloaded</b><span>${esc(t.down_total_h)}</span></div><div><b>Uploaded</b><span>${esc(t.up_total_h)}</span></div><div><b>Labels</b><span>${labels||'<span class="text-muted">-</span>'}</span></div><div><b>Ratio group</b><span>${esc(t.ratio_group||'')}</span></div></div>`:'Select a torrent.'; }
const FILE_PRIORITY_LABELS = {0: "Skip", 1: "Normal", 2: "High"};
function priorityClass(priority){ priority=Number(priority||0); return priority===2?"text-bg-success":priority===0?"text-bg-secondary":"text-bg-primary"; }
function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return `<select class="form-select form-select-sm file-priority" data-index="${esc(f.index)}"><option value="0" ${p===0?"selected":""}>Skip</option><option value="1" ${p===1?"selected":""}>Normal</option><option value="2" ${p===2?"selected":""}>High</option></select>`; }
function renderFiles(files){
const pane=$('detailPane');
const rows=(files||[]).map(f=>`<tr data-file-index="${esc(f.index)}"><td class="sel"><input class="file-check" type="checkbox" data-index="${esc(f.index)}"></td><td class="path" title="${esc(f.path)}">${esc(f.path)}</td><td>${esc(f.size_h)}</td><td>${esc(f.progress??0)}%</td><td><span class="badge ${priorityClass(f.priority)}">${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}</span></td><td>${renderFilePrioritySelect(f)}</td></tr>`).join('');
pane.innerHTML=`<div class="files-toolbar"><div class="btn-group btn-group-sm"><button class="btn btn-outline-secondary file-priority-bulk" data-priority="0"><i class="fa-solid fa-ban"></i> Skip selected</button><button class="btn btn-outline-primary file-priority-bulk" data-priority="1"><i class="fa-solid fa-bars"></i> Normal selected</button><button class="btn btn-outline-success file-priority-bulk" data-priority="2"><i class="fa-solid fa-arrow-up"></i> High selected</button></div><span class="small text-muted">Changes are applied immediately in rTorrent.</span></div><table class="table table-sm detail-table file-priority-table"><thead><tr><th><input id="fileSelectAll" type="checkbox"></th><th>Path</th><th>Size</th><th>Done</th><th>Priority</th><th>Set</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty">No files.</td></tr>'}</tbody></table>`;
}
async function setFilePriorities(items){
if(!selectedHash || !items.length) return;
setBusy(true);
try{
const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});
const j=await res.json();
if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');
toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');
await loadDetails('files');
}catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }
}
function peerBadges(p){
const badges=[];
if(p.encrypted) badges.push('<span class="badge text-bg-success">enc</span>');
if(p.incoming) badges.push('<span class="badge text-bg-info">in</span>');
if(p.snubbed) badges.push('<span class="badge text-bg-warning">snub</span>');
if(p.banned) badges.push('<span class="badge text-bg-danger">ban</span>');
return badges.join(' ') || '<span class="text-muted">-</span>';
}
function renderPeers(peers){
const rows=(peers||[]).map(p=>[flag(p.country_iso),`<span class="peer-ip">${esc(p.ip)}<a class="peer-ip-link" href="https://ipinfo.io/${encodeURIComponent(p.ip||'')}" target="_blank" rel="noopener noreferrer" title="Open IP info"><i class="fa-solid fa-link"></i></a></span>`,esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p)]);
$('detailPane').innerHTML=table(['Flag','IP','Country','City','Client','%','DL','UL','Port','Flags'],rows);
}
function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }
function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? "-"} / ${t.peers ?? "-"}` : "-"; }
function renderTrackers(trackers){
// Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.
const pane=$('detailPane');
const list=trackers||[];
const canDelete=list.length>1;
const rows=list.map(t=>{
const idx=esc(t.index), url=esc(t.url);
const deleteDisabled=canDelete ? '' : ' disabled title="At least one tracker must remain"';
return [`<span class="text-muted">#${idx}</span>`, `<span class="tracker-url-text">${url || '<span class="text-muted">-</span>'}</span>`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `<div class="tracker-actions"><button class="btn btn-xs btn-outline-danger tracker-delete" data-index="${idx}"${deleteDisabled}><i class="fa-solid fa-trash"></i> Delete</button></div>`];
});
pane.innerHTML=`<div class="tracker-toolbar"><div class="input-group input-group-sm"><input id="trackerAddUrl" class="form-control tracker-add-input" placeholder="https://tracker.example/announce"><button id="trackerAddBtn" class="btn btn-outline-primary"><i class="fa-solid fa-plus"></i> Add tracker</button></div><button id="trackerReannounceBtn" class="btn btn-sm btn-outline-primary"><i class="fa-solid fa-bullhorn"></i> Reannounce</button></div>${table(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '<span class="text-muted">-</span>','<span class="text-muted">No trackers.</span>','','','','','' ]])}`;
}
async function trackerAction(action,payload={}){
if(!selectedHash) return toast('No torrent selected','warning');
setBusy(true);
try{
const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);
toast(j.message || `Tracker ${action} done`,'success');
await loadDetails('trackers');
}catch(e){toast(e.message,'danger');}
finally{setBusy(false);}
}
async function loadDetails(tab){ const t=torrents.get(selectedHash); if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers'); setupPeersRefresh(tab); if(!t)return; if(tab==='general') return renderGeneral(); if(tab==='log'){ $('detailPane').innerHTML=`<pre>${esc(t.message||'No logs')}</pre>`; return; } const pane=$('detailPane'); pane.innerHTML=`<div class="loading-line"><span class="spinner-border spinner-border-sm"></span> Loading ${esc(tab)}...</div>`; try{ const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`,{headers:{'Accept':'application/json'}}); const text=await res.text(); let json; try{ json=JSON.parse(text); }catch(parseErr){ throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`); } if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`); if(tab!==activeTab()) return; if(tab==='files') renderFiles(json.files||[]); if(tab==='peers') renderPeers(json.peers||[]); if(tab==='trackers') renderTrackers(json.trackers||[]); }catch(e){pane.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`;} }
function copyText(text){
text=String(text ?? '');
if(navigator.clipboard && window.isSecureContext){
return navigator.clipboard.writeText(text);
}
return new Promise((resolve,reject)=>{
const ta=document.createElement('textarea');
ta.value=text; ta.setAttribute('readonly','');
ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='0';
document.body.appendChild(ta); ta.focus(); ta.select();
try{ document.execCommand('copy') ? resolve() : reject(new Error('copy command failed')); }
catch(e){ reject(e); }
finally{ ta.remove(); }
});
}
function copySelected(field){
const t=torrents.get(selectedHash);
if(!t) return toast('No torrent selected','warning');
const value=String(t[field] ?? '');
if(!value) return toast(`No ${field} to copy`,'warning');
copyText(value).then(()=>toast(`Copied ${field}`,'success')).catch(()=>toast('Copy failed','danger'));
}
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 || '/'; }
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; }
async function openPathPicker(target){ pathTarget=target; const def=await getDefaultDownloadPath(); const initial=def || ($(target)?.value||'/'); $('moveOptions')?.classList.toggle('d-none', target!=='move'); if($('moveDataPhysical')) $('moveDataPhysical').checked=true; if($('moveRecheck')) $('moveRecheck').checked=true; new bootstrap.Modal($('pathModal')).show(); browsePath(initial); }
async function browsePath(path){ $('pathList').innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading...'; try{ const res=await fetch(`/api/path/browse?path=${encodeURIComponent(path||'/')}`); const j=await res.json(); if(!j.ok) throw new Error(j.error); $('pathCurrent').value=j.path; lastPathParent=j.parent; $('pathList').innerHTML=j.dirs.map(d=>`<div class="path-row" data-path="${esc(d.path)}"><i class="fa-solid fa-folder"></i><span>${esc(d.name)}</span></div>`).join('')||'<div class="p-3 text-muted">No directories.</div>'; }catch(e){$('pathList').innerHTML=`<div class="text-danger p-2">${esc(e.message)}</div>`;} }
$('pathList')?.addEventListener('click',e=>{const r=e.target.closest('.path-row'); if(r) browsePath(r.dataset.path);}); $('pathGoBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathUpBtn')?.addEventListener('click',()=>browsePath(lastPathParent)); $('pathReloadBtn')?.addEventListener('click',()=>browsePath($('pathCurrent').value)); $('pathSelectBtn')?.addEventListener('click',async()=>{const p=$('pathCurrent').value; if(pathTarget==='move'){ const hashes=selectedHashes(); const j=await post('/api/torrents/move',{hashes,path:p,move_data:!!($('moveDataPhysical')?.checked),recheck:!!($('moveRecheck')?.checked)}); markQueuedJobs(j,hashes,'move'); const parts=Number(j.bulk_parts||1); toast(parts>1?`move queued in ${parts} bulk parts`:$('moveDataPhysical')?.checked?'physical move queued':'move queued','success'); } else if($(pathTarget)) $(pathTarget).value=p; bootstrap.Modal.getInstance($('pathModal'))?.hide();}); document.querySelectorAll('.browse-path').forEach(b=>b.addEventListener('click',()=>openPathPicker(b.dataset.target)));
function renderColumnManager(){ const box=$('columnManager'); if(!box) return; box.innerHTML=COLUMN_DEFS.map(([key,label])=>`<label class="column-card form-check form-switch ${hiddenColumns.has(key)?'':'active'}"><input class="form-check-input column-toggle" type="checkbox" data-col-key="${esc(key)}" ${hiddenColumns.has(key)?'':'checked'}><span class="form-check-label"><i class="fa-solid fa-table-columns"></i> ${esc(label)}</span></label>`).join(''); }
$('saveColumnsBtn')?.addEventListener('click',async()=>{ document.querySelectorAll('.column-toggle').forEach(cb=>cb.checked?hiddenColumns.delete(cb.dataset.colKey):hiddenColumns.add(cb.dataset.colKey)); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[...hiddenColumns]})}).catch(e=>toast(e.message,'danger')); toast('Columns saved','success'); });
$('resetColumnsBtn')?.addEventListener('click',async()=>{ hiddenColumns.clear(); renderColumnManager(); applyColumnVisibility(); scheduleRender(true); await post('/api/preferences',{table_columns_json:JSON.stringify({hidden:[]})}).catch(()=>{}); });
function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(`<button class="btn btn-xs btn-outline-primary job-retry" data-id="${id}"><i class="fa-solid fa-rotate-left"></i> retry</button>`); if(status==='pending'||status==='running') actions.push(`<button class="btn btn-xs btn-outline-danger job-cancel" data-id="${id}" data-status="${esc(status)}"><i class="fa-solid fa-triangle-exclamation"></i> emergency cancel</button>`); return actions.join(' ') || '<span class="text-muted">-</span>'; }
function jobStatusBadgeClass(status){
// Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.
const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};
return classes[String(status||'')] || 'warning';
}
async function loadJobs(page=jobsPage){
const box=$('jobsTable');
// Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.
if(!box) return;
jobsPage=Math.max(0,page|0);
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading jobs...';
const offset=jobsPage*jobsLimit;
const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();
const rows=j.jobs||[];
jobsTotal=Number(j.total||rows.length);
const details=r=>{
const count=Number(r.hash_count||0);
if(r.is_bulk || count>1) return `<span class="badge text-bg-info">bulk</span><br><span class="text-muted">${esc(count)} torrent(s), details hidden</span>`;
const bits=[];
if(count) bits.push(`${esc(count)} torrent`);
if(r.summary) bits.push(esc(r.summary));
return bits.join('<br>') || '-';
};
box.innerHTML=responsiveTable(
['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],
rows.map(r=>[
`<span class="badge text-bg-${jobStatusBadgeClass(r.status)}">${esc(r.status)}</span>`,
esc(r.action),
esc(r.profile_id),
esc(r.hash_count||0),
details(r),
esc(r.attempts||0),
humanDateCell(r.started_at||r.created_at),
humanDateCell(r.finished_at),
compactCell(r.error||'',140),
jobActions(r),
]),
'jobs-table'
);
renderJobsPager();
}
function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`<div class="d-flex align-items-center gap-2 flex-wrap"><button class="btn btn-sm btn-outline-secondary" id="jobsPrev" ${jobsPage<=0?'disabled':''}><i class="fa-solid fa-chevron-left"></i> Prev</button><span class="small text-muted">Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs</span><button class="btn btn-sm btn-outline-secondary" id="jobsNext" ${jobsPage>=pages-1?'disabled':''}>Next <i class="fa-solid fa-chevron-right"></i></button></div>`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }
// Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.
$('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); 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-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(); });
$('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',{}); toast(`Cleared ${j.deleted||0} finished job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });
$('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',{}); toast(`Emergency cleared ${j.deleted||0} job log(s)`,'success'); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });
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=>`<div class="label-manager-row"><span class="chip"><i class="fa-solid fa-tag"></i> ${esc(l.name)}</span><button class="btn btn-xs btn-outline-danger delete-label" data-id="${esc(l.id)}" title="Delete label"><i class="fa-solid fa-trash"></i></button></div>`).join(''):'<span class="text-muted">No labels.</span>'; }
function renderLabelChooser(){ if($('selectedLabelList')) $('selectedLabelList').innerHTML=[...modalLabels].map(l=>`<button class="chip label-selected" data-label="${esc(l)}" title="Remove"><i class="fa-solid fa-tag"></i> ${esc(l)} <i class="fa-solid fa-xmark ms-1"></i></button>`).join('') || '<span class="text-muted small">No labels selected.</span>'; if($('labelList')) $('labelList').innerHTML=knownLabels.map(l=>`<button class="chip label-chip ${modalLabels.has(l.name)?'active':''}" data-label="${esc(l.name)}"><i class="fa-solid fa-tag"></i> ${esc(l.name)}</button>`).join('') || '<span class="text-muted small">No saved labels.</span>'; }
async function saveKnownLabel(name){ name=String(name||'').trim(); if(!name) return; await post('/api/labels',{name}); await loadLabels(); }
async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value="${esc(g.name)}">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=table(['Name','Min','Max','Seed min','Action','Enabled'],groups.map(g=>[esc(g.name),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes),esc(g.action),g.enabled?'yes':'no'])); }
$('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });
$('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(); });
$('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(); });
$('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });
$('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(); });
$('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });
$('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });
$('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}); loadRatios(); });
async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[]; if($('rssManager')) $('rssManager').innerHTML=`<h6>Feeds</h6>${table(['Name','URL','Last error'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.last_error||'')]))}<h6 class="mt-3">Rules</h6>${table(['Name','Pattern','Path','Label'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.save_path),esc(r.label)]))}`; }
function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }
function smartQueueToastMessage(r){ const noEffect=r.start_no_effect?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_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 tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const failTail=stopFailed?`, stop failed ${stopFailed}`:''; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${failTail}${cap}`; }
function buildSmartQueueNerdStats(hist=[], totalHistory=0){
// Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.
const stats=hist.reduce((acc,h)=>{
const details=smartHistoryDetails(h);
const stopped=Number(h.paused_count||0);
const started=Number(h.resumed_count||0);
const checked=Number(h.checked_count||0);
const over=Number(details.over_limit||0);
const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;
acc.checked += checked;
acc.stopped += stopped;
acc.started += started;
acc.overLimit += over;
acc.stopFailed += stopFailed;
if(over>0) acc.overEvents += 1;
return acc;
},{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});
const latest=hist[0]||null;
return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:latest?.event||'-',latestAt:latest?.created_at||''};
}
function renderSmartQueueNerdStats(stats){
// Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.
if(!stats) return '<div class="automation-smart-stats empty-mini">No Smart Queue stats yet.</div>';
const cards=[
['Runs',stats.total,`${stats.sample} loaded`],
['Checked',stats.checked,'torrent scans'],
['Stopped',stats.stopped,'queue trims'],
['Started',stats.started,'queue fills'],
['Over limit',stats.overEvents,`${stats.overLimit} total over`],
['Stop failed',stats.stopFailed,'rTorrent rejects'],
['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],
];
return `<div class="automation-smart-stats" aria-label="Smart Queue nerd stats">${cards.map(([label,value,hint])=>`<div class="automation-smart-stat"><span>${esc(label)}</span><b>${esc(value)}</b><small>${hint}</small></div>`).join('')}</div>`;
}
async function loadSmartQueue(){
if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');
if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');
const historyLimit=smartHistoryExpanded?100:10;
const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();
if(!j.ok) return;
const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];
const totalHistory=Number(j.history_total ?? hist.length);
if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;
if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;
if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;
if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);
if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;
if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;
if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;
if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;
if($('smartManager')){
$('smartManager').innerHTML=ex.length
? responsiveTable(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),`<button class="btn btn-xs btn-outline-danger smart-unexclude" data-hash="${esc(x.torrent_hash)}"><i class="fa-solid fa-xmark"></i> remove exception</button>`]),'smart-exclusions-table')
: '<div class="empty-mini"><i class="fa-solid fa-circle-info"></i> No Smart Queue exceptions. Select torrents and use <b>Exclude selected</b> to keep them outside the queue.</div>';
}
if($('smartHistory')){
const body=hist.length
? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Started','Stop failed'],hist.map(h=>{ const d=smartHistoryDetails(h); return [dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(d.active_before??'-'),esc(d.max_active_downloads??'-'),esc(d.over_limit??0),esc(h.paused_count||0),esc(h.resumed_count||0),esc((d.stop_failed||[]).length||0)]; }),'smart-history-table')
: '<div class="empty-mini">No Smart Queue operations yet.</div>';
const canToggle=totalHistory>10;
const toggle=canToggle?`<button id="smartHistoryToggle" class="btn btn-xs btn-outline-secondary mt-2">${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)})</button>`:'';
$('smartHistory').innerHTML=`${body}${toggle}`;
}
}
async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,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}); toast('Smart Queue saved','success'); await loadSmartQueue(); }
async function loadAuthUsers(){
if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;
const [usersRes, profilesRes]=await Promise.all([fetch('/api/auth/users'), fetch('/api/profiles')]);
const usersJson=await usersRes.json();
const profilesJson=await profilesRes.json();
const profiles=profilesJson.profiles||[];
if($('authProfile')) $('authProfile').innerHTML=`<option value="0">All profiles</option>`+profiles.map(p=>`<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('');
const rows=(usersJson.users||[]).map(u=>{
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');
return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),`<button class="btn btn-xs btn-outline-secondary auth-edit" data-user='${esc(JSON.stringify(u))}'><i class="fa-solid fa-pen"></i></button> <button class="btn btn-xs btn-outline-danger auth-delete" data-id="${esc(u.id)}"><i class="fa-solid fa-trash"></i></button>`];
});
$('authUsersManager').innerHTML=rows.length?table(['User','Role','Active','Profile rights','Actions'],rows):'<div class="empty-mini">No users.</div>';
}
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'); }
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'); }
async function saveAuthUser(){
const id=$('authUserId')?.value||'';
const role=$('authRole')?.value||'user';
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'}]};
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'); }
}
function normalizeRtConfigValue(value, type='text'){
const raw=String(value ?? '').trim();
if(type==='bool') return ['1','true','yes','on'].includes(raw.toLowerCase()) ? '1' : '0';
if(type==='number'){
if(raw==='') return '0';
const normalized=Number(raw.replace(',', '.'));
return Number.isFinite(normalized) ? String(Math.trunc(normalized)) : raw;
}
return raw;
}
function rtConfigInputValue(input){
const type=input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text';
const value=type==='bool' && input.type==='checkbox' ? (input.checked?'1':'0') : input.value;
return normalizeRtConfigValue(value, type);
}
function rtConfigOriginalValue(input){
const key=input.dataset.key;
return normalizeRtConfigValue(input.dataset.original ?? rtConfigOriginal.get(key), input.dataset.type || rtConfigFieldTypes.get(key) || 'text');
}
function collectRtConfigChanges(){
const values={};
document.querySelectorAll('.rt-config-input').forEach(input=>{
if(input.disabled) return;
const cur=rtConfigInputValue(input);
const orig=rtConfigOriginalValue(input);
if(cur!==orig) values[input.dataset.key]=cur;
});
return values;
}
function collectRtConfigClearKeys(){
const keys=[];
document.querySelectorAll('.rt-config-input').forEach(input=>{
if(input.disabled || input.dataset.saved!=='true') return;
const cur=rtConfigInputValue(input);
const orig=rtConfigOriginalValue(input);
if(cur===orig) keys.push(input.dataset.key);
});
return keys;
}
function updateRtConfigDirty(){
const changed=collectRtConfigChanges();
const clearKeys=collectRtConfigClearKeys();
document.querySelectorAll('.rt-config-input').forEach(input=>{
const row=input.closest('.rt-config-row');
if(row) row.classList.toggle('changed', Object.prototype.hasOwnProperty.call(changed,input.dataset.key));
});
const configChanges=Object.keys(changed).length;
const applyChanged=!!$('rtConfigApplyOnStart') && $('rtConfigApplyOnStart').checked!==rtConfigOriginalApplyOnStart;
const total=configChanges + clearKeys.length + (applyChanged ? 1 : 0);
if($('rtConfigChangedCount')) $('rtConfigChangedCount').textContent=total?`${total} changed`:'No changes';
if($('rtConfigGenerateBtn')) $('rtConfigGenerateBtn').disabled=!configChanges;
if($('rtConfigSaveBtn')) $('rtConfigSaveBtn').disabled=!total;
}
async function loadRtConfig(){
const box=$('rtConfigManager');
if(!box)return;
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading config...';
try{
const j=await (await fetch('/api/rtorrent-config')).json();
if(!j.ok) throw new Error(j.error||'Config load failed');
const fields=j.config?.fields||[];
rtConfigOriginal=new Map();
rtConfigFieldTypes=new Map();
rtConfigOriginalApplyOnStart=!!j.config?.apply_on_start;
let lastGroup='';
const html=fields.map(f=>{
const group=f.group||'Other';
const head=group!==lastGroup?`<div class="rt-config-group">${esc(group)}</div>`:'';
lastGroup=group;
const disabled=(!f.ok||f.readonly)?'disabled':'';
const type=['bool','number'].includes(f.type)?f.type:'text';
const originalValue=normalizeRtConfigValue(f.baseline_value ?? f.current_value ?? f.value, type);
const displayValue=normalizeRtConfigValue(f.saved ? f.saved_value : (f.value ?? f.current_value), type);
rtConfigOriginal.set(f.key, originalValue);
rtConfigFieldTypes.set(f.key, type);
const note=f.ok?(f.readonly?' · read only':(f.saved?' · saved override · reference kept':'')):' · unavailable';
const valueNote=f.saved?`<small class="rt-config-value-note">Reference: ${esc(originalValue)} → saved: ${esc(displayValue)}</small>`:'';
const originalAttr=esc(originalValue);
const input=type==='bool'
? `<span class="form-check form-switch rt-config-switch"><input class="form-check-input rt-config-input" data-key="${esc(f.key)}" data-type="bool" data-original="${originalAttr}" data-saved="${f.saved?'true':'false'}" type="checkbox" ${displayValue==='1'?'checked':''} ${disabled}><span class="form-check-label">${displayValue==='1'?'On':'Off'}</span></span>`
: `<input class="form-control form-control-sm rt-config-input" data-key="${esc(f.key)}" data-type="${esc(type)}" data-original="${originalAttr}" data-saved="${f.saved?'true':'false'}" type="${type==='number'?'number':'text'}" value="${esc(displayValue)}" placeholder="${esc(f.placeholder||'')}" ${disabled}>`;
return `${head}<label class="rt-config-row ${f.ok?'':'disabled'} ${f.changed?'changed-live':''}"><span><b>${esc(f.label)}</b><small>${esc(f.key)}${note}</small>${valueNote}</span>${input}</label>`;
}).join('');
box.innerHTML=`<div class="rt-config-grid">${html}</div>`;
if($('rtConfigApplyOnStart')) $('rtConfigApplyOnStart').checked=rtConfigOriginalApplyOnStart;
updateRtConfigDirty();
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
}
async function saveRtConfig(){
const values=collectRtConfigChanges();
const clear_keys=collectRtConfigClearKeys();
clear_keys.forEach(key=>{
const input=document.querySelector(`.rt-config-input[data-key="${CSS.escape(key)}"]`);
if(input) values[key]=rtConfigOriginalValue(input);
});
setBusy(true);
try{
const j=await post('/api/rtorrent-config',{values,clear_keys,apply_on_start:!!$('rtConfigApplyOnStart')?.checked,apply_now:true});
toast(`rTorrent config saved (${j.result?.updated?.length||0})`,'success');
await loadRtConfig();
}catch(e){
toast(e.message,'danger');
} finally{
setBusy(false);
}
}
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'); } }
function bootstrapThemeUrl(theme){ /* Notatka: motywy korzystają z mapy URL wygenerowanej przez backend, więc działają także offline. */ const key=theme||"default"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || ""; }
function applyBootstrapTheme(theme){ bootstrapTheme = theme || "default"; const link=$("bootstrapThemeStylesheet"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($("bootstrapThemeSelect")) $("bootstrapThemeSelect").value = bootstrapTheme; }
function applyFontFamily(font){ fontFamily = font || "default"; document.documentElement.dataset.appFont = fontFamily; if($("fontFamilySelect")) $("fontFamilySelect").value = fontFamily; }
async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } }
if($("titleSpeedEnabled")) $("titleSpeedEnabled").checked=titleSpeedEnabled;
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); } }
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); }
let automationRulesCache=[];
let automationConditions=[];
let automationEffects=[];
function automationCondition(){
const type=$('autoConditionType')?.value||'completed';
const cond={type, negate:!!$('autoCondNegate')?.checked};
if(type==='no_seeds'){ cond.seeds=Number($('autoCondSeeds')?.value||0); cond.minutes=Number($('autoCondMinutes')?.value||0); }
if(type==='ratio_gte') cond.ratio=Number($('autoCondRatio')?.value||1);
// Note: Progress conditions compare the torrent completion percentage stored in the live torrent row.
if(type==='progress_gte'||type==='progress_lte') cond.progress=Number($('autoCondProgress')?.value||0);
if(type==='label_missing'||type==='label_has') cond.label=$('autoCondLabel')?.value||'';
if(type==='status') cond.status=$('autoCondStatus')?.value||'Seeding';
if(type==='path_contains') cond.text=$('autoCondText')?.value||'';
return cond;
}
function automationEffect(){
const type=$('autoEffectType')?.value||'add_label';
const eff={type};
if(type==='move'){
eff.path=$('autoEffectPath')?.value||'';
eff.move_data=!!$('autoMoveData')?.checked;
eff.recheck=!!$('autoMoveRecheck')?.checked;
eff.keep_seeding=!!$('autoMoveKeepSeeding')?.checked;
}
if(type==='add_label'||type==='remove_label') eff.label=$('autoEffectLabel')?.value||'';
if(type==='set_labels') eff.labels=$('autoEffectLabels')?.value||'';
return eff;
}
function updateAutomationForm(){
const ct=$('autoConditionType')?.value||'';
document.querySelectorAll('[data-auto-cond]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoCond.split(',').includes(ct)));
const et=$('autoEffectType')?.value||'';
document.querySelectorAll('[data-auto-effect]').forEach(el=>el.classList.toggle('d-none', !el.dataset.autoEffect.split(',').includes(et)));
}
function conditionText(c={}){
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';
return c.negate?`NOT (${base})`:base;
}
function effectText(e={}){
if(e.type==='move'){
const flags=[];
if(e.move_data) flags.push('move data');
if(e.recheck) flags.push('recheck');
if(e.keep_seeding) flags.push('keep seeding');
return `move to ${e.path||'default path'}${flags.length?` (${flags.join(', ')})`:''}`;
}
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;
}
function ruleSummary(r){
const cs=(r.conditions||[]).map(conditionText).join(' + ')||'no conditions';
const es=(r.effects||[]).map(effectText).join(' → ')||'no actions';
return `${cs}${es}`;
}
function renderAutomationBuilder(){
const cBox=$('automationConditionList');
if(cBox) cBox.innerHTML=automationConditions.length?automationConditions.map((c,i)=>`<span class="automation-chip"><b>IF</b> ${esc(conditionText(c))}<button class="btn btn-xs btn-link automation-remove-condition" data-index="${i}" type="button"><i class="fa-solid fa-xmark"></i></button></span>`).join(''):'<span class="text-muted small">No conditions added yet.</span>';
const eBox=$('automationEffectList');
if(eBox) eBox.innerHTML=automationEffects.length?automationEffects.map((e,i)=>`<span class="automation-chip"><b>${i+1}</b> ${esc(effectText(e))}<button class="btn btn-xs btn-link automation-remove-effect" data-index="${i}" type="button"><i class="fa-solid fa-xmark"></i></button></span>`).join(''):'<span class="text-muted small">No actions added yet.</span>';
}
function resetAutomationForm(){
if($('autoEditId')) $('autoEditId').value='';
if($('autoName')) $('autoName').value='';
if($('autoEnabled')) $('autoEnabled').checked=true;
if($('autoCooldown')) $('autoCooldown').value='60';
automationConditions=[]; automationEffects=[];
$('automationCancelEditBtn')?.classList.add('d-none');
if($('automationSaveBtn')) $('automationSaveBtn').innerHTML='<i class="fa-solid fa-floppy-disk"></i> Save rule';
renderAutomationBuilder(); updateAutomationForm();
}
function editAutomationRule(rule){
if(!rule) return;
if($('autoEditId')) $('autoEditId').value=rule.id||'';
if($('autoName')) $('autoName').value=rule.name||'';
if($('autoEnabled')) $('autoEnabled').checked=!!rule.enabled;
if($('autoCooldown')) $('autoCooldown').value=rule.cooldown_minutes ?? 60;
automationConditions=Array.isArray(rule.conditions)?JSON.parse(JSON.stringify(rule.conditions)):[];
automationEffects=Array.isArray(rule.effects)?JSON.parse(JSON.stringify(rule.effects)):[];
$('automationCancelEditBtn')?.classList.remove('d-none');
if($('automationSaveBtn')) $('automationSaveBtn').innerHTML='<i class="fa-solid fa-floppy-disk"></i> Update rule';
renderAutomationBuilder();
}
function summarizeActionObject(a={}){
if(a.error) return `<span class="badge text-bg-danger">${esc(a.error)}</span>`;
const count=a.count || a.result?.count || a.result?.results?.length || '';
const parts=[];
if(a.type) parts.push(a.type);
if(count) parts.push(`${count} torrent(s)`);
if(a.path) parts.push(a.path);
if(a.label) parts.push(`label ${a.label}`);
if(a.labels) parts.push(`labels ${a.labels}`);
if(a.move_data) parts.push('move data');
if(a.recheck) parts.push('recheck');
if(a.keep_seeding) parts.push('keep seeding');
return `<span class="automation-action-pill">${esc(parts.join(' · ')||'action')}</span>`;
}
function automationHistoryActions(raw){
let actions=[];
try{ actions=JSON.parse(raw||'[]'); }catch(e){ return `<div class="automation-history-raw">${esc(raw||'')}</div>`; }
if(!Array.isArray(actions)) actions=[actions];
const summary=actions.map(summarizeActionObject).join(' ');
const details=esc(JSON.stringify(actions,null,2));
// Note: Large automation payloads are collapsed so JSON never stretches the modal width.
return `<details class="automation-history-details"><summary>${summary||'No actions'}</summary><pre>${details}</pre></details>`;
}
function renderAutomationHistory(hist=[], smartStats=automationSmartQueueStats){
if(!$('automationHistory')) return;
const stats=renderSmartQueueNerdStats(smartStats);
const toolbar='<div class="automation-history-toolbar"><button id="automationClearHistoryBtn" class="btn btn-xs btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear history</button></div>';
const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);
// Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.
const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'<div class="empty-mini">No automation history yet.</div>';
$('automationHistory').innerHTML=stats+toolbar+body;
}
async function clearAutomationHistory(){
if(!confirm('Clear automation history?')) return;
setBusy(true);
try{ const j=await fetch('/api/automations/history',{method:'DELETE'}).then(r=>r.json()); if(!j.ok) throw new Error(j.error||'Clear automation history failed'); toast(`Automation logs deleted: ${j.deleted||0}`,'success'); renderAutomationHistory(j.history||[]); }
catch(e){ toast(e.message,'danger'); }
finally{ setBusy(false); }
}
async function exportAutomations(){
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'); }
catch(e){ toast(e.message,'danger'); }
}
async function importAutomations(file){
if(!file) return;
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(); }
catch(e){ toast(e.message||'Automation import failed','danger'); }
finally{ if($('automationImportFile')) $('automationImportFile').value=''; }
}
async function loadAutomations(){
const [j,smart]=await Promise.all([
fetch('/api/automations').then(r=>r.json()),
fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({}))
]);
const rules=j.rules||[], hist=j.history||[];
// Note: Automations only display Smart Queue diagnostics here; saving/checking rules remains unchanged.
automationSmartQueueStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;
automationRulesCache=rules;
if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>{
const enabled=!!r.enabled;
const toggleTitle=enabled?'Disable automation':'Enable automation';
const toggleIcon=enabled?'fa-toggle-on':'fa-toggle-off';
const toggleClass=enabled?'btn-outline-warning':'btn-outline-success';
return `<div class="automation-row"><div class="automation-row-main"><div><b>${esc(r.name)}</b> ${enabled?'<span class="badge text-bg-success">on</span>':'<span class="badge text-bg-secondary">off</span>'}</div><div class="small text-muted automation-rule-summary">${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min</div></div><div class="automation-row-actions"><button class="btn btn-xs ${toggleClass} automation-toggle" data-id="${esc(r.id)}" type="button" title="${toggleTitle}"><i class="fa-solid ${toggleIcon}"></i></button><button class="btn btn-xs btn-outline-secondary automation-edit" data-id="${esc(r.id)}" type="button" title="Edit automation"><i class="fa-solid fa-pen"></i></button><button class="btn btn-xs btn-outline-danger automation-delete" data-id="${esc(r.id)}" type="button" title="Delete automation"><i class="fa-solid fa-trash"></i></button></div></div>`;
}).join(''):'<div class="empty-mini">No automation rules.</div>';
renderAutomationHistory(hist, automationSmartQueueStats);
}
async function toggleAutomationRule(rule){
if(!rule) return;
const payload={...rule, enabled:!rule.enabled};
// Note: Toggle keeps the rule definition unchanged and only switches automatic execution on or off.
setBusy(true);
try{ await post('/api/automations',payload); toast(payload.enabled?'Automation enabled':'Automation disabled','success'); await loadAutomations(); }
catch(e){ toast(e.message,'danger'); }
finally{ setBusy(false); }
}
async function saveAutomation(){
const currentCond=automationCondition();
const currentEff=automationEffect();
const conditions=automationConditions.length?automationConditions:[currentCond];
const effects=automationEffects.length?automationEffects:[currentEff];
const payload={id:Number($('autoEditId')?.value||0)||undefined,name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions,effects};
setBusy(true);
try{ await post('/api/automations',payload); toast(payload.id?'Automation rule updated':'Automation rule saved','success'); resetAutomationForm(); await loadAutomations(); }
catch(e){toast(e.message,'danger');}
finally{setBusy(false);}
}
function cleanupCountCard(label, value, note=''){
return `<div class="cleanup-card"><b>${esc(label)}</b><span>${esc(value ?? 0)}</span>${note?`<small>${esc(note)}</small>`:''}</div>`;
}
function renderCleanup(data={}){
const box=$('cleanupManager'); if(!box) return;
const retention=data.retention_days||{};
const db=data.database||{};
const cards=[
cleanupCountCard('Job logs total', data.jobs_total, `retention ${retention.jobs||'-'} days`),
cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),
cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, `retention ${retention.smart_queue_history||'-'} days`),
cleanupCountCard('Automation logs', data.automation_history_total, `retention ${retention.automation_history||'-'} days`),
cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')
];
box.innerHTML=`<div class="cleanup-grid">${cards.join('')}</div><div class="cleanup-actions mt-3"><button id="cleanupJobsBtn" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i> Clear job logs</button><button id="cleanupSmartQueueBtn" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i> Clear Smart Queue logs</button><button id="cleanupAutomationsBtn" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-trash"></i> Clear automation logs</button><button id="cleanupAllBtn" class="btn btn-sm btn-danger"><i class="fa-solid fa-broom"></i> Clear logs</button><button id="cleanupRefreshBtn" class="btn btn-sm btn-outline-secondary"><i class="fa-solid fa-rotate"></i> Refresh</button></div><div class="tool-note mt-2">Job cleanup preserves pending and running jobs. Automation cleanup removes only history, not rules.</div>`;
}
async function loadCleanup(){
const box=$('cleanupManager'); if(!box) return;
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading cleanup data...';
try{
const j=await (await fetch('/api/cleanup/summary')).json();
if(!j.ok) throw new Error(j.error||'Cleanup summary failed');
renderCleanup(j.cleanup||{});
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
}
async function runCleanupAction(endpoint, label){
if(!confirm(`${label}?`)) return;
setBusy(true);
try{
const j=await post(endpoint,{});
const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);
toast(`Cleanup done (${deleted})`,'success');
renderCleanup(j.cleanup||{});
if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }
if(endpoint.includes('/smart-queue')) loadSmartQueue().catch(()=>{});
if(endpoint.includes('/automations')) loadAutomations().catch(()=>{});
}catch(e){ toast(e.message,'danger'); }
finally{ setBusy(false); }
}
function diagCard(label,value,extra=''){ return `<div class="diag-card ${extra}"><b>${esc(label)}</b><span>${esc(value ?? '-')}</span></div>`; }
// Note: Centralizes footer visibility so Preferences can hide items without removing existing status logic.
function applyFooterPreferences(){
document.querySelectorAll('[data-footer-item]').forEach(el=>{
const key=el.dataset.footerItem;
el.classList.toggle('footer-pref-hidden', footerItems[key] === false);
});
}
function renderFooterPreferences(){
const box=$('footerPreferences');
if(!box) return;
box.innerHTML=FOOTER_ITEM_DEFS.map(([key,label])=>`<label class="footer-pref-card form-check form-switch ${footerItems[key]===false?'':'active'}"><input class="form-check-input footer-pref-toggle" type="checkbox" data-footer-key="${esc(key)}" ${footerItems[key]===false?'':'checked'}><span class="form-check-label">${esc(label)}</span></label>`).join('');
}
async function saveFooterPreferences(){
document.querySelectorAll('.footer-pref-toggle').forEach(cb=>{ footerItems[cb.dataset.footerKey] = !!cb.checked; });
applyFooterPreferences();
renderFooterPreferences();
try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }
catch(e){ toast(e.message,'danger'); }
}
function compactSpeedText(value){
// Notatka: stopka ma ograniczone miejsce, więc usuwa spację tylko z etykiet prędkości.
return String(value || '0 B/s').replace(/\s+(?=[KMGT]?i?B\/s$|B\/s$)/, '');
}
function speedPairText(down, up){
// Notatka: spójny zapis pary DL/UL jest używany w stopce i diagnostyce.
return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;
}
function peakDateText(value){
// Notatka: skraca ISO timestamp z bazy do czytelnej etykiety w podpowiedzi.
return value ? String(value).replace('T',' ').replace(/\+00:00$/, ' UTC') : '-';
}
function updateSpeedPeaks(peaks={}){
// Notatka: prezentuje rekord sesji i rekord ogólny obok bieżących prędkości w stopce.
const session=peaks.session||{};
const allTime=peaks.all_time||{};
const sessionText=speedPairText(session.down_h, session.up_h);
const allTimeText=speedPairText(allTime.down_h, allTime.up_h);
if($('statPeakSession')) $('statPeakSession').textContent=sessionText;
if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;
const box=$('statusSpeedPeaks');
if(box){
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)}`;
}
}
function updateBrowserSpeedTitle(downH, upH){
// Notatka: w stylu ruTorrent pokazuje DL/UL w tytule karty; window.status jest próbą dla starszych przeglądarek.
if(downH != null) lastBrowserSpeed.down=downH || '0 B/s';
if(upH != null) lastBrowserSpeed.up=upH || '0 B/s';
const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;
document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;
try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}
}
async function saveTitleSpeedPreference(){
// Notatka: zmiana działa od razu i jest zapisywana jako preferencja użytkownika.
titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;
updateBrowserSpeedTitle();
try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }
catch(e){ toast(e.message,'danger'); }
}
async function saveTrackerFaviconsPreference(){
// Note: Tracker favicon toggle changes only icon rendering; tracker filter counts and actions stay untouched.
trackerFaviconsEnabled=!!$('trackerFaviconsEnabled')?.checked;
renderTrackerFilters();
try{ await post('/api/preferences',{tracker_favicons_enabled:trackerFaviconsEnabled}); toast('Tracker favicon preference saved','success'); }
catch(e){ toast(e.message,'danger'); }
}
function updateFooterClock(){
const el=$('statClock');
if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});
}
function updateSocketStatus(s={}){
const el=$('statSockets');
if(!el) return;
const open=s.open_sockets;
const max=s.max_open_sockets;
el.textContent=open == null ? '-' : (max == null ? String(open) : `${open}/${max}`);
const box=$('statusSockets');
if(box) box.title=open == null ? 'Open sockets unavailable from this rTorrent build' : `Open rTorrent sockets${max == null ? '' : ' / max'}: ${el.textContent}`;
}
function portStatusLabel(st){ return st==='open'?'open':st==='closed'?'closed':st==='disabled'?'disabled':st==='error'?'error':'unknown'; }
function portStatusClass(st){ return st==='open'?'port-ok':st==='closed'?'port-bad':'port-secondary'; }
function portStatusIcon(st){ return st==='open'?'fa-circle-check':st==='closed'?'fa-circle-xmark':'fa-circle-question'; }
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 `<span ${attrs}class="port-status ${portStatusClass(st)}"><i class="fa-solid ${portStatusIcon(st)}"></i> ${esc(label)}</span>`; }
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 ''; }
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; }
function renderPortCheck(data={}){
if($('portCheckEnabled')) $('portCheckEnabled').checked=!!data.enabled;
const details=portCheckDetails(data);
const title=details.join(' · ') || 'Port check disabled';
if($('portCheckBadge')) $('portCheckBadge').outerHTML=portStatusBadge(data,'id="portCheckBadge" ');
if($('portCheckInfo')) $('portCheckInfo').textContent=details.join(' · ') || 'Uses YouGetSignal first. Manual check bypasses the 6h cache.';
if($('statusPortCheck')){
$('statusPortCheck').classList.toggle('d-none', !data.enabled);
$('statusPortCheck').title=title;
}
if($('statusPortCheckBadge')) $('statusPortCheckBadge').outerHTML=portStatusBadge(data,'id="statusPortCheckBadge" ',true);
}
async function loadPreferences(){ if($('portCheckEnabled')) $('portCheckEnabled').checked=portCheckEnabled; applyBootstrapTheme(bootstrapTheme); applyFontFamily(fontFamily); renderFooterPreferences(); applyFooterPreferences(); await loadPortCheck(false); }
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'); } }
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}); } }
async function loadAppStatus(){
const box=$('appStatusManager'); if(!box) return;
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading diagnostics...';
try{
const j=await (await fetch('/api/app/status')).json();
if(!j.ok) throw new Error(j.error||'Failed to load diagnostics');
const st=j.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};
const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};
const cards=[
diagCard('pyTorrent PID', py.pid), diagCard('pyTorrent 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('Jobs total', py.jobs_total),
diagCard('Worker threads', py.worker_threads), diagCard('Python', py.python||'-'), diagCard('DB size', db.size_h||'-'),
diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`),
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)),
diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-'),
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('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||'-')
];
box.innerHTML=`<div class="diag-grid">${cards.join('')}</div>${scgi.error?`<div class="alert alert-danger mt-3 mb-0">${esc(scgi.error)}</div>`:''}`;
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
}
function torrentStatsCard(label, value, note=''){
return `<div class="torrent-stats-card"><b>${esc(label)}</b><span>${esc(value ?? '-')}</span>${note?`<small>${esc(note)}</small>`:''}</div>`;
}
function renderTorrentStats(stats={}){
const box=$('torrentStatsManager');
if(!box) return;
const age=Number(stats.age_seconds||0);
const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\+00:00$/,' UTC') : '-';
const cards=[
torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),
torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),
torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`),
torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample'),
torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`),
torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')
];
if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;
const errors=Array.isArray(stats.errors)&&stats.errors.length ? `<div class="alert alert-warning py-2 mt-3 mb-0">File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}</div>` : '';
box.innerHTML=`<div class="torrent-stats-grid">${cards.join('')}</div>${errors}`;
}
async function loadTorrentStats(force=false){
const box=$('torrentStatsManager');
if(!box) return;
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading torrent statistics...';
try{
const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();
if(!j.ok) throw new Error(j.error||'Torrent statistics failed');
renderTorrentStats(j.stats||{});
if(force) toast('Torrent statistics refreshed','success');
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
}
$('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();loadAuthUsers();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences(); if(tool==='users') loadAuthUsers();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(del && confirm('Delete user?')){ await fetch(`/api/auth/users/${del.dataset.id}`,{method:'DELETE'}); loadAuthUsers(); } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});
$('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toast(`Automations applied ${torrents} torrent(s) in ${batches} batch(es)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });
document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });
$('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});
$('smartExcludeSelectedBtn')?.addEventListener('click',()=>setSmartException(selectedHashes(),true,'manual'));
$('smartIncludeSelectedBtn')?.addEventListener('click',()=>setSmartException(selectedHashes(),false,'manual'));
$('smartHistory')?.addEventListener('click',e=>{ const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue(); });
document.addEventListener('change',e=>{ const sel=e.target.closest('#mobileFilterSelect'); if(!sel)return; activeFilter=sel.value; document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter===activeFilter)); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); });
function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }
document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.filter').forEach(x=>{ if(x.dataset.filter===mobileFilter.dataset.filter) x.classList.add('active'); }); activeFilter=mobileFilter.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=h; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });
document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });
document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);
document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s')runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });
$('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});
$('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));
$('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));
$('addBtn')?.addEventListener('click',async()=>{const btn=$('addBtn');buttonBusy(btn,true);setBusy(true);try{const fd=new FormData();fd.append('uris',$('magnetInput').value);fd.append('directory',$('addPath').value);fd.append('label',$('addLabel').value);fd.append('start',$('addStart').checked?'1':'0');[...($('torrentFiles')?.files||[])].forEach(f=>fd.append('files',f));const j=await (await fetch('/api/torrents/add',{method:'POST',body:fd})).json();if(!j.ok)throw new Error(j.error||'Add failed');$('magnetInput').value='';$('torrentFiles').value='';toast('Add queued','success');bootstrap.Modal.getInstance($('addModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('torrentFiles')?.addEventListener('change',()=>{$('torrentFilesInfo').textContent=$('torrentFiles').files.length?`Selected files: ${$('torrentFiles').files.length}`:'You can select multiple files at once.';});
const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;
const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;
function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }
function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }
function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }
function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }
function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }
document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));
document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));
['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));
$('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});
async function refreshProfiles(){ $('profileList').innerHTML='<span class="spinner-border spinner-border-sm me-2"></span>Loading profiles...'; const j=await (await fetch('/api/profiles')).json(); const active=j.active?.id; profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); $('profileList').innerHTML=(j.profiles||[]).map(p=>`<div class="profile-row ${p.id===active?'active':''}"><b>${esc(p.name)} ${p.id===active?"<span class='badge text-bg-primary ms-1'>active</span>":''} ${p.is_remote?"<span class='badge text-bg-secondary ms-1'>remote</span>":''}</b><span>${esc(p.scgi_url)} · jobs ${esc(p.max_parallel_jobs||5)}${p.is_remote?' · remote CPU/RAM/IP':''}</span><div class="profile-actions"><button class="btn btn-xs btn-outline-primary" data-use-profile="${p.id}"><i class="fa-solid fa-plug-circle-check"></i> use</button><button class="btn btn-xs btn-outline-secondary" data-edit-profile="${p.id}" title="Edit"><i class="fa-solid fa-pen-to-square"></i></button><button class="btn btn-xs btn-outline-danger" data-del-profile="${p.id}" title="Delete"><i class="fa-solid fa-trash"></i></button></div></div>`).join('')||'No profiles.'; }
function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class="fa-solid fa-plus"></i> Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }
function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class="fa-solid fa-floppy-disk"></i> Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }
// Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.
$('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{
try{
const j=await (await fetch('/api/profiles')).json();
const select=$('profileSelect');
if(select) select.innerHTML=(j.profiles||[]).map(p=>`<option value="${esc(p.id)}" ${j.active?.id===p.id?'selected':''}>${esc(p.name)}</option>`).join('') || '<option value="">No profiles configured</option>';
}catch(e){}
}); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile;if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){setBusy(true);await post(`/api/profiles/${use}/activate`,{});setBusy(false);location.reload();}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload={name:$('profileName').value,scgi_url:$('profileUrl').value,timeout_seconds:$('profileTimeout').value,max_parallel_jobs:$('profileParallel').value,is_remote:$('profileRemote')?.checked};const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;await post(`/api/profiles/${id}/activate`,{});const opt=e.target.selectedOptions?.[0];if($('activeProfileName') && opt) $('activeProfileName').textContent=opt.textContent || 'rTorrent';bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();defaultDownloadPath=null;applyDefaultDownloadPath(true).catch(()=>{});socket.emit('select_profile',{profile_id:id});hasTorrentSnapshot=false;torrents.clear();selected.clear();scheduleRender(true);});
// Note: Opens the existing rTorrent form directly from the empty first-run state.
document.addEventListener('click',e=>{ if(!e.target.closest('#setupProfileBtn')) return; activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); });
// Note: On a fresh install there is no rTorrent snapshot to wait for, so open the app and show setup immediately.
function showFirstRunSetup(){
if(hasActiveProfile || firstRunSetupShown) return;
firstRunSetupShown = true;
$('connBadge').className='badge text-bg-warning';
$('connBadge').textContent='setup required';
setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');
renderNoProfileState();
hideInitialLoader();
setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);
}
$('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();
function drawTraffic(down,up){ traffic.push({down:Number(down||0),up:Number(up||0)}); if(traffic.length>60)traffic.shift(); const c=$('trafficChart'); if(!c)return; const ctx=c.getContext('2d'),w=c.width,h=c.height; ctx.clearRect(0,0,w,h); const max=Math.max(1,...traffic.map(p=>Math.max(p.down,p.up))); ctx.beginPath(); traffic.forEach((p,i)=>{const x=i*(w/59),y=h-(p.down/max)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#38bdf8'; ctx.stroke(); ctx.beginPath(); traffic.forEach((p,i)=>{const x=i*(w/59),y=h-(p.up/max)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#f59e0b'; ctx.stroke(); }
function drawSystemUsage(cpu,ram){
const c=$('systemChart'); if(!c) return;
const cpuVal=Math.max(0,Math.min(100,Number(cpu||0)));
const ramVal=Math.max(0,Math.min(100,Number(ram||0)));
systemUsage.push({cpu:cpuVal,ram:ramVal}); if(systemUsage.length>60) systemUsage.shift();
const ctx=c.getContext('2d'), w=c.width, h=c.height; ctx.clearRect(0,0,w,h);
ctx.fillStyle='rgba(148,163,184,.18)'; ctx.fillRect(0,0,w,h);
ctx.beginPath(); systemUsage.forEach((p,i)=>{const x=i*(w/Math.max(1,systemUsage.length-1)), y=h-(p.cpu/100)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#a78bfa'; ctx.stroke();
ctx.beginPath(); systemUsage.forEach((p,i)=>{const x=i*(w/Math.max(1,systemUsage.length-1)), y=h-(p.ram/100)*h; i?ctx.lineTo(x,y):ctx.moveTo(x,y);}); ctx.strokeStyle='#22c55e'; ctx.stroke();
c.title=`CPU ${cpuVal.toFixed(1)}% / RAM ${ramVal.toFixed(1)}%`;
}
function drawDiskUsage(disk){
const box=$('diskStatus'), label=$('statDisk'), c=$('diskChart');
if(!box||!label||!c)return;
const ctx=c.getContext('2d'), w=c.width, h=c.height;
ctx.clearRect(0,0,w,h);
const ok=disk&&disk.ok;
const pct=ok?Math.max(0,Math.min(100,Number(disk.percent||0))):0;
label.textContent=ok?`${pct.toFixed(pct%1?1:0)}%`:'-';
box.classList.toggle('disk-warn', !ok || pct>=90);
box.title=ok?`Disk ${disk.path||'default path'}
Used: ${disk.used_h||'-'} / ${disk.total_h||'-'}
Free: ${disk.free_h||'-'}${disk.fallback?`
Measured on: ${disk.source_path}`:''}`:`Disk usage unavailable${disk?.error?`
${disk.error}`:''}`;
ctx.fillStyle='rgba(148,163,184,.22)'; ctx.fillRect(0,5,w,14);
ctx.fillStyle=pct>=90?'#ef4444':pct>=75?'#f59e0b':'#22c55e'; ctx.fillRect(0,5,Math.round(w*pct/100),14);
ctx.strokeStyle='rgba(148,163,184,.55)'; ctx.strokeRect(.5,5.5,w-1,13);
}
async function loadTrafficHistory(range="7d"){
const info=$('trafficHistoryInfo');
const volume=$('trafficHistoryChart');
const speed=$('trafficSpeedChart');
if(info) info.textContent='Loading...';
try{
const res=await fetch(`/api/traffic/history?range=${encodeURIComponent(range)}`);
const j=await res.json();
if(!j.ok) throw new Error(j.error||'Failed to load history');
drawTrafficHistory(j.history||{rows:[]});
if(info){
const rows=(j.history&&j.history.rows)||[];
const bucket=(j.history&&j.history.bucket)||'bucket';
info.textContent=rows.length ? `${rows.length} ${bucket} bucket(s), retention ${j.history?.retention_days||90} days.` : 'No retained samples yet. Data is stored every minute while pyTorrent is running.';
}
}catch(e){
if(info) info.textContent=e.message;
[volume,speed].forEach(c=>{ if(c) c.getContext('2d').clearRect(0,0,c.width,c.height); });
}
}
function setupCanvas(canvas){
const rect=canvas.getBoundingClientRect();
const dpr=window.devicePixelRatio||1;
const cssW=Math.max(320, Math.floor(rect.width || canvas.parentElement?.clientWidth || 900));
const cssH=Math.max(320, Math.floor(rect.height || 420));
canvas.width=Math.floor(cssW*dpr); canvas.height=Math.floor(cssH*dpr);
const ctx=canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
return {ctx,w:cssW,h:cssH};
}
function drawAxes(ctx,w,h){ ctx.strokeStyle='rgba(148,163,184,.35)'; ctx.lineWidth=1; ctx.beginPath(); ctx.moveTo(42,12); ctx.lineTo(42,h-28); ctx.lineTo(w-12,h-28); ctx.stroke(); }
function fmtBytes(v){ v=Number(v||0); const u=['B','KiB','MiB','GiB','TiB']; let i=0; while(v>=1024&&i<u.length-1){v/=1024;i++;} return `${v.toFixed(i?1:0)} ${u[i]}`; }
function compactTransferText(value){ return String(value || '0 B').replace(/\s+(?=[KMGT]?i?B$)/, ''); }
function drawLine(ctx,pts,w,h,color){ ctx.strokeStyle=color; ctx.lineWidth=2; ctx.beginPath(); pts.forEach((p,i)=>{ const x=42+(i*Math.max(1,(w-58)/Math.max(1,pts.length-1))); const y=h-28-p; i?ctx.lineTo(x,y):ctx.moveTo(x,y); }); ctx.stroke(); }
function drawTrafficHistory(hist){
const rows=hist.rows||[];
const volume=$('trafficHistoryChart'), speed=$('trafficSpeedChart');
if(!volume||!speed) return;
const bodyColor=getComputedStyle(document.body).color;
const muted='rgba(148,163,184,.75)';
function legend(ctx, x, y, unit){
ctx.fillStyle=bodyColor; ctx.font='12px system-ui'; ctx.fillText(`Download / Upload (${unit})`, x, y);
ctx.fillStyle='#38bdf8'; ctx.fillRect(x, y+7, 10, 10); ctx.fillStyle=bodyColor; ctx.fillText('Download', x+14, y+17);
ctx.fillStyle='#f59e0b'; ctx.fillRect(x+92, y+7, 10, 10); ctx.fillStyle=bodyColor; ctx.fillText('Upload', x+106, y+17);
}
function yLabels(ctx, max, suffix, w, h){
ctx.fillStyle=muted; ctx.font='11px system-ui';
ctx.fillText(fmtBytes(max)+suffix, 6, 18);
ctx.fillText(fmtBytes(max/2)+suffix, 6, Math.round((h-28+12)/2));
ctx.fillText('0 '+suffix.trim(), 24, h-12);
}
function xLabels(ctx, values, w, h){
if(!values.length) return;
ctx.fillStyle=muted; ctx.font='11px system-ui';
const first=String(values[0]||''), last=String(values[values.length-1]||'');
ctx.fillText(first.slice(-10), 44, h-8);
const tw=ctx.measureText(last.slice(-10)).width;
ctx.fillText(last.slice(-10), Math.max(48, w-12-tw), h-8);
}
let c=setupCanvas(volume), ctx=c.ctx,w=c.w,h=c.h; ctx.clearRect(0,0,w,h); drawAxes(ctx,w,h);
if(!rows.length){
ctx.fillStyle=bodyColor; ctx.fillText('No history yet. Samples appear after pyTorrent records traffic.',52,36);
const sc=setupCanvas(speed); sc.ctx.clearRect(0,0,sc.w,sc.h); sc.ctx.fillStyle=bodyColor; sc.ctx.fillText('No speed samples yet.',52,36);
return;
}
const labels=rows.map(r=>r.bucket);
const maxVol=Math.max(1,...rows.map(r=>Math.max(Number(r.downloaded||0),Number(r.uploaded||0))));
const usable=w-58, bw=Math.max(2, Math.min(26, usable/rows.length-3));
rows.forEach((r,i)=>{
const x=44+i*(usable/rows.length); const dh=(Number(r.downloaded||0)/maxVol)*(h-60); const uh=(Number(r.uploaded||0)/maxVol)*(h-60);
ctx.fillStyle='#38bdf8'; ctx.fillRect(x,h-28-dh,bw/2,dh);
ctx.fillStyle='#f59e0b'; ctx.fillRect(x+bw/2,h-28-uh,bw/2,uh);
});
legend(ctx,52,16,'data'); yLabels(ctx,maxVol,'',w,h); xLabels(ctx,labels,w,h);
c=setupCanvas(speed); ctx=c.ctx; w=c.w; h=c.h; ctx.clearRect(0,0,w,h); drawAxes(ctx,w,h);
const maxSpeed=Math.max(1,...rows.map(r=>Math.max(Number(r.avg_down_rate||0),Number(r.avg_up_rate||0))));
const scale=h-60; const dl=rows.map(r=>Number(r.avg_down_rate||0)/maxSpeed*scale); const ul=rows.map(r=>Number(r.avg_up_rate||0)/maxSpeed*scale);
drawLine(ctx,dl,w,h,'#38bdf8'); drawLine(ctx,ul,w,h,'#f59e0b');
legend(ctx,52,16,'B/s'); yLabels(ctx,maxSpeed,'/s',w,h); xLabels(ctx,labels,w,h);
}
$('trafficModal')?.addEventListener("show.bs.modal",()=>loadTrafficHistory("7d"));
document.querySelectorAll(".traffic-range").forEach(b=>b.addEventListener("click",()=>{
document.querySelectorAll(".traffic-range").forEach(x=>{x.classList.remove("btn-primary");x.classList.add("btn-outline-secondary");});
b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary");
loadTrafficHistory(b.dataset.range||"7d");
}));
socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{patchRows(msg);scheduleTrackerSummary(false);}); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{
const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined;
$('statCpuBox')?.classList.toggle('d-none',!usageAvailable);
$('statRamBox')?.classList.toggle('d-none',!usageAvailable);
$('systemChart')?.classList.toggle('d-none',!usageAvailable);
if(usageAvailable){
$('statCpu').textContent=s.cpu??'-';
$('statRam').textContent=s.ram??'-';
drawSystemUsage(s.cpu,s.ram);
}
$('statVersion').textContent=s.version||'-';
$('statDl').textContent=s.down_rate_h||'0 B/s';
$('statUl').textContent=s.up_rate_h||'0 B/s';
if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';
if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';
lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};
$('statDlLimit').textContent=s.down_limit_h||'∞';
$('statUlLimit').textContent=s.up_limit_h||'∞';
$('statTotalDl').textContent=compactTransferText(s.total_down_h);
$('statTotalUl').textContent=compactTransferText(s.total_up_h);
updateSpeedPeaks(s.speed_peaks||{});
updateBrowserSpeedTitle(s.down_rate_h||'0 B/s', s.up_rate_h||'0 B/s');
drawTraffic(s.down_rate,s.up_rate);
drawDiskUsage(s.disk);
updateSocketStatus(s);
applyFooterPreferences();
});
updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); scheduleTrackerSummary(true);
})();