Files
pyTorrent/pytorrent/static/app.js
Mateusz Gruszczyński ef22a3196d footer imrpove
2026-05-04 10:01:51 +02:00

752 lines
105 KiB
JavaScript

(() => {
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 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";
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"],
["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();
function toast(msg, type="secondary") { const h=$('toastHost'); if(!h) return; const el=document.createElement('div'); el.className=`toast-item text-bg-${type}`; el.innerHTML=esc(msg); h.appendChild(el); setTimeout(()=>el.remove(),3500); }
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>`; }
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'};
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 setFilterSummary(type){
const el=$(FILTER_COUNT_IDS[type]);
if(!el) return;
const bucket=torrentSummary?.filters?.[type] || {count:0};
const meta=filterMetaLine(bucket, type);
const tooltip=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, ', ')}` : '';
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 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.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); 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 renderCounts(){
Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);
$('statSelected').textContent=selected.size;
}
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('')}`:''; box.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 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 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 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'])); 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: ':''}${esc(label)} (${count})</option>`).join(''); 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><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"><i class="fa-solid fa-play"></i></button><button class="btn btn-xs btn-outline-warning" data-action="pause"><i class="fa-solid fa-pause"></i></button><button class="btn btn-xs btn-outline-secondary" data-action="stop"><i class="fa-solid fa-stop"></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(); 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); markTorrentOperation(hashes, action, j.job_id, 'queued'); 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); } toast(`${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){ return `<table class="table table-sm detail-table"><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 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),esc(p.ip),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),`<div class="peer-actions"><button class="btn btn-xs btn-outline-warning peer-action" data-peer-index="${esc(p.index)}" data-peer-action="disconnect" title="Kick peer"><i class="fa-solid fa-user-slash"></i><span>Kick</span></button><button class="btn btn-xs btn-outline-secondary peer-action" data-peer-index="${esc(p.index)}" data-peer-action="snub" title="Snub peer"><i class="fa-solid fa-volume-xmark"></i><span>Snub</span></button><button class="btn btn-xs btn-outline-primary peer-action" data-peer-index="${esc(p.index)}" data-peer-action="unsnub" title="Unsnub peer"><i class="fa-solid fa-volume-high"></i><span>Unsnub</span></button><button class="btn btn-xs btn-outline-danger peer-action" data-peer-index="${esc(p.index)}" data-peer-action="ban" title="Ban peer if supported"><i class="fa-solid fa-ban"></i><span>Ban</span></button></div>`]);
$('detailPane').innerHTML=table(['Flag','IP','Country','City','Client','%','DL','UL','Port','Flags','Actions'],rows);
}
async function peerAction(index, action){
if(!selectedHash) return;
setBusy(true);
try{
const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/peers/action`,{peer_index:Number(index),action});
toast(j.message || `Peer ${action} done`,'success');
await loadDetails('peers');
}catch(e){ toast(e.message,'danger'); }
finally{ setBusy(false); }
}
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){
const pane=$('detailPane');
const rows=(trackers||[]).map(t=>{
const idx=esc(t.index), url=esc(t.url);
return [`<span class="text-muted">#${idx}</span>`, `<div class="tracker-url-view" data-tracker-index="${idx}"><span class="tracker-url-text">${url || '<span class="text-muted">-</span>'}</span></div><div class="tracker-url-edit d-none" data-tracker-index="${idx}"><input class="form-control form-control-sm tracker-url" data-tracker-index="${idx}" value="${url}"></div>`, 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-secondary tracker-edit-start" data-index="${idx}"><i class="fa-solid fa-pen"></i> Edit</button><button class="btn btn-xs btn-outline-primary tracker-edit-save d-none" data-index="${idx}"><i class="fa-solid fa-floppy-disk"></i> Save</button><button class="btn btn-xs btn-outline-secondary tracker-edit-cancel d-none" data-index="${idx}"><i class="fa-solid fa-xmark"></i> Cancel</button></div>`];
});
pane.innerHTML=`<div class="tracker-toolbar"><div class="input-group input-group-sm"><input id="trackerAddUrl" class="form-control" 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>','','','','','' ]])}`;
}
function setTrackerEdit(index,on){ const sel=String(index); document.querySelector(`.tracker-url-view[data-tracker-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', on); document.querySelector(`.tracker-url-edit[data-tracker-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); document.querySelector(`.tracker-edit-start[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', on); document.querySelector(`.tracker-edit-save[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); document.querySelector(`.tracker-edit-cancel[data-index="${CSS.escape(sel)}"]`)?.classList.toggle('d-none', !on); }
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)}); markTorrentOperation(hashes,'move',j.job_id,'queued'); toast($('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(()=>{}); });
async function loadJobs(page=jobsPage){ const box=$('jobsTable'); 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=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`<span class="badge text-bg-${r.status==='done'?'success':r.status==='failed'?'danger':r.status==='running'?'primary':r.status==='cancelled'?'secondary':'warning'}">${esc(r.status)}</span>`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),`<button class="btn btn-xs btn-outline-primary job-retry" data-id="${esc(r.id)}"><i class="fa-solid fa-rotate-left"></i> retry</button> <button class="btn btn-xs btn-outline-danger job-cancel" data-id="${esc(r.id)}"><i class="fa-solid fa-ban"></i> cancel</button>`])); 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)); }
$('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')) 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} 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)]))}`; }
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($('smartManager')) $('smartManager').innerHTML=ex.length?table(['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>`])):'<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?table(['Time','Event','Checked','Paused','Resumed'],hist.map(h=>[dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(h.paused_count||0),esc(h.resumed_count||0)])):'<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}); toast('Smart Queue saved','success'); await loadSmartQueue(); }
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){ return theme && theme !== "default" ? `https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/${encodeURIComponent(theme)}/bootstrap.min.css` : "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"; }
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"); } }
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); }
function automationCondition(){ const type=$('autoConditionType')?.value||'completed'; const cond={type}; 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); 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||''; 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 ruleSummary(r){ const cs=(r.conditions||[]).map(c=>c.type==='no_seeds'?`no seeds <=${c.seeds||0} for ${c.minutes||0} min`:c.type==='ratio_gte'?`ratio >= ${c.ratio}`: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').join(' + '); const es=(r.effects||[]).map(e=>e.type==='move'?`move to ${e.path||'default path'}`: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).join(' + '); return `${cs}${es}`; }
async function loadAutomations(){ const j=await (await fetch('/api/automations')).json(); const rules=j.rules||[], hist=j.history||[]; if($('automationManager')) $('automationManager').innerHTML=rules.length?rules.map(r=>`<div class="automation-row"><div><b>${esc(r.name)}</b> ${r.enabled?'<span class="badge text-bg-success">on</span>':'<span class="badge text-bg-secondary">off</span>'}<div class="small text-muted">${esc(ruleSummary(r))} · cooldown ${esc(r.cooldown_minutes||0)} min</div></div><button class="btn btn-xs btn-outline-danger automation-delete" data-id="${esc(r.id)}"><i class="fa-solid fa-trash"></i></button></div>`).join(''):'<div class="empty-mini">No automation rules.</div>'; if($('automationHistory')) $('automationHistory').innerHTML=hist.length?table(['Time','Rule','Torrent','Actions'],hist.map(h=>[esc(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),esc(h.actions_json||'')])):'<div class="empty-mini">No automation history yet.</div>'; }
async function saveAutomation(){ const payload={name:$('autoName')?.value||'Automation rule',enabled:!!$('autoEnabled')?.checked,cooldown_minutes:Number($('autoCooldown')?.value||60),conditions:[automationCondition()],effects:[automationEffect()]}; setBusy(true); try{ await post('/api/automations',payload); toast('Automation rule saved','success'); 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('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="cleanupAllBtn" class="btn btn-sm btn-danger"><i class="fa-solid fa-broom"></i> Clear both</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 uses the existing job endpoint logic, so pending and running jobs are preserved.</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(()=>{});
}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 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 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('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_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>`; }
}
$('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',preferences:'toolPreferences',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==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('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||{}; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}`,'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('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue 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); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{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();});
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; awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); 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 b=e.target.closest('.peer-action'); if(!b) return; peerAction(b.dataset.peerIndex,b.dataset.peerAction); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const editStart=e.target.closest('.tracker-edit-start'); if(editStart){ setTrackerEdit(editStart.dataset.index,true); return; } const cancel=e.target.closest('.tracker-edit-cancel'); if(cancel){ setTrackerEdit(cancel.dataset.index,false); return; } const save=e.target.closest('.tracker-edit-save'); if(save){ const input=document.querySelector(`.tracker-url[data-tracker-index="${CSS.escape(String(save.dataset.index))}"]`); trackerAction('edit',{index:Number(save.dataset.index),url:input?.value||''}); 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); $('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);hideInitialLoader();}); socket.on('torrent_patch',patchRows); 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(`Smart Queue: paused ${msg.paused?.length||0}, resumed ${msg.resumed?.length||0}`,'secondary'); }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); 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);drawTraffic(s.down_rate,s.up_rate);drawDiskUsage(s.disk);updateSocketStatus(s);applyFooterPreferences();});
updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); 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(()=>{});
})();