first commit

This commit is contained in:
gru
2026-05-03 12:35:57 +01:00
commit 4fac1716a6
31 changed files with 5949 additions and 0 deletions

640
pytorrent/static/app.js Normal file
View File

@@ -0,0 +1,640 @@
(() => {
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";
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 activeOperations = new Map();
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>`; }
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 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){
if(tooltip){
button.title=tooltip;
button.setAttribute('aria-label', `${button.dataset.filter || type}: ${tooltip.replace(/\n/g, ', ')}`);
} else {
button.removeAttribute('title');
button.removeAttribute('aria-label');
}
}
}
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 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 !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return t.status!=='Checking' && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return t.status==='Checking' || Number(t.hashing||0)>0; if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state; 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 ${hiddenColumns.has(key)?'':'active'}"><input class="form-check-input column-toggle" type="checkbox" data-col-key="${esc(key)}" ${hiddenColumns.has(key)?'':'checked'}><span><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){
return normalizeRtConfigValue(input.value, input.dataset.type || rtConfigFieldTypes.get(input.dataset.key) || 'text');
}
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'
? `<select class="form-select form-select-sm rt-config-input" data-key="${esc(f.key)}" data-type="bool" data-original="${originalAttr}" data-saved="${f.saved?'true':'false'}" ${disabled}><option value="0" ${displayValue==='0'?'selected':''}>Off</option><option value="1" ${displayValue==='1'?'selected':''}>On</option></select>`
: `<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>`; }
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 port=data.port?String(data.port):'-'; 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.port) bits.push(`Port: ${data.port}`); 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); 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');}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>{const tool=b.dataset.tool||'rtorrents'; document.querySelectorAll('.tool-tab').forEach(x=>x.classList.remove('active')); b.classList.add('active'); showToolPanel(tool); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();})); $('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')) 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);
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 one rTorrent'; 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'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML='<i class="fa-solid fa-floppy-disk"></i> Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }
$('profileModal')?.addEventListener('show.bs.modal',refreshProfiles); $('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();}); $('saveBulkProfilesBtn')?.addEventListener('click',async()=>{const lines=($('bulkProfiles').value||'').split(/\n+/).map(x=>x.trim()).filter(Boolean);setBusy(true);try{for(const line of lines){const [name,scgi_url]=line.split('|').map(x=>x.trim());if(name&&scgi_url)await post('/api/profiles',{name,scgi_url,timeout_seconds:$('profileTimeout').value,max_parallel_jobs:$('profileParallel').value,is_remote:$('profileRemote')?.checked});}location.reload();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('profileSelect')?.addEventListener('change',async e=>{await post(`/api/profiles/${e.target.value}/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:e.target.value});hasTorrentSnapshot=false;torrents.clear();selected.clear();scheduleRender(true);}); $('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',()=>{ $('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',()=>{ $('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('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);});
updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); scheduleRender(true); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); applyDefaultDownloadPath(false).catch(()=>{});
})();

808
pytorrent/static/styles.css Normal file
View File

@@ -0,0 +1,808 @@
:root {
--app-font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
--topbar: 50px;
--statusbar: 34px;
--sidebar: 270px;
--torrent-progress-complete: #198754;
}
[data-bs-theme="dark"] {
--bs-body-bg: #05070a;
--bs-body-bg-rgb: 5,7,10;
--bs-body-color: #d6dde8;
--bs-secondary-bg: #0a0f16;
--bs-secondary-bg-rgb: 10,15,22;
--bs-tertiary-bg: #0e141d;
--bs-border-color: #1d2734;
--bs-secondary-color: #8d98aa;
--bs-primary-bg-subtle: #0d2238;
--bs-primary-text-emphasis: #9ecbff;
--torrent-progress-complete: #2f9e75;
}
html[data-app-font="adwaita-mono"] { --app-font-family: "Adwaita Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
html[data-app-font="inter"] { --app-font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
html[data-app-font="system-ui"] { --app-font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
html[data-app-font="source-sans-3"] { --app-font-family: "Source Sans 3", "Source Sans Pro", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
html[data-app-font="jetbrains-mono"] { --app-font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
html, body { height: 100%; }
body {
overflow: hidden;
font-size: 13px;
padding: 8px;
background: #05070a;
font-family: var(--app-font-family);
}
.app-shell {
height: calc(100vh - 16px);
display: grid;
grid-template-rows: var(--topbar) 1fr var(--statusbar);
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 12px 45px rgba(0,0,0,.38);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
padding: .42rem .7rem;
min-height: var(--topbar);
background: var(--bs-secondary-bg);
}
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: .45rem; min-width: 0; }
.toolbar-left { flex: 0 1 auto; overflow: hidden; }
.toolbar-right { flex: 1 1 0; justify-content: flex-end; margin-left: auto; }
.brand { font-weight: 800; font-size: 1.05rem; letter-spacing: .2px; white-space: nowrap; line-height: 32px; }
.profile-picker-btn { max-width: 180px; }
.profile-picker-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.profile-select { width: 100%; }
.search { width: min(38vw, 420px); min-width: clamp(160px, 20vw, 220px); max-width: 420px; flex: 0 1 420px; }
.mobile-speed-stats { display: none; align-items: center; gap: .45rem; flex: 0 0 auto; color: var(--bs-secondary-color); font-size: .72rem; white-space: nowrap; }
.mobile-speed-stats b { color: var(--bs-body-color); font-weight: 700; }
.topbar .form-control, .topbar .form-select { height: 32px; line-height: 1.15; }
.topbar .btn { min-height: 28px; line-height: 1; }
#themeToggle, #mobileToggle { width: 32px; min-width: 32px; display: inline-flex; align-items: center; justify-content: center; }
.spinner-border-xs { width: .75rem; height: .75rem; border-width: .12em; vertical-align: -1px; }
.global-loader {
position: fixed;
right: 14px;
bottom: 44px;
z-index: 7000;
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .4rem .65rem;
border-radius: 999px;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
border: 1px solid var(--bs-border-color);
box-shadow: 0 8px 28px rgba(0,0,0,.35);
}
.initial-loader {
position: fixed;
inset: 0;
z-index: 9000;
display: grid;
place-items: center;
padding: 1rem;
background: radial-gradient(circle at 50% 35%, rgba(var(--bs-secondary-bg-rgb), .98), var(--bs-body-bg) 68%);
color: var(--bs-body-color);
transition: opacity .22s ease, visibility .22s ease;
}
.initial-loader.is-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.initial-loader-card {
width: min(92vw, 430px);
padding: 2rem;
border: 1px solid var(--bs-border-color);
border-radius: 18px;
background: rgba(var(--bs-secondary-bg-rgb), .88);
box-shadow: 0 24px 70px rgba(0,0,0,.48);
text-align: center;
}
.initial-loader-brand {
font-size: 1.35rem;
font-weight: 800;
letter-spacing: .2px;
}
.initial-loader-spinner {
margin: 1.4rem 0 1rem;
}
.initial-loader-title {
font-size: 1rem;
font-weight: 700;
}
.initial-loader-text {
margin-top: .35rem;
color: var(--bs-secondary-color);
}
.main-grid { min-height: 0; display: grid; grid-template-columns: var(--sidebar) 1fr; }
.sidebar { padding: .65rem; overflow: auto; background: rgba(var(--bs-secondary-bg-rgb), .9); }
/* Note: Sidebar filters are wider and use one structured block per class to avoid duplicate overrides. */
.filter {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: .15rem .55rem;
align-items: center;
margin-bottom: .2rem;
padding: .45rem .6rem;
border: 0;
border-radius: .55rem;
background: transparent;
color: var(--bs-body-color);
text-align: left;
}
.filter:hover,
.filter.active {
background: var(--bs-primary-bg-subtle);
color: var(--bs-primary-text-emphasis);
}
.filter > span:first-child {
min-width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter > span:last-child {
min-width: 0;
max-width: 12rem;
text-align: right;
}
.filter-count {
display: block;
font-weight: 700;
line-height: 1.1;
}
.filter-meta {
display: block;
margin-top: .05rem;
color: var(--bs-secondary-color);
font-size: .68rem;
font-weight: 400;
line-height: 1.15;
opacity: .72;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter.active .filter-meta,
.filter:hover .filter-meta {
color: var(--bs-primary-text-emphasis);
opacity: .78;
}
.shortcut { font-size: .78rem; color: var(--bs-secondary-color); padding: .15rem .5rem; }
.content { min-width: 0; min-height: 0; display: grid; grid-template-rows: 1fr 255px; }
.table-wrap { overflow: auto; contain: content; }
.torrent-table { margin: 0; white-space: nowrap; table-layout: auto; }
.torrent-table thead th { position: sticky; top: 0; z-index: 2; background: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); user-select: none; }
.torrent-table thead th[data-sort] { cursor: pointer; }
.torrent-table thead th[data-sort]:hover, .torrent-table thead th.sorted { color: var(--bs-primary-text-emphasis); }
.sort-icon { opacity: .85; }
.torrent-table tbody tr { cursor: default; height: 36px; }
.torrent-table tbody tr.selected td { background: var(--bs-primary-bg-subtle); }
.torrent-table .sel { width: 34px; text-align: center; }
.torrent-table .name { min-width: 280px; max-width: 520px; overflow: hidden; text-overflow: ellipsis; }
.torrent-table .path { max-width: 360px; overflow: hidden; text-overflow: ellipsis; color: var(--bs-secondary-color); }
.virtual-spacer td { padding: 0 !important; border: 0 !important; }
.empty { height: 120px; text-align: center; vertical-align: middle; color: var(--bs-secondary-color); }
.progress.thin { height: 7px; min-width: 130px; margin-bottom: 1px; background: rgba(255,255,255,.08); }
.details { min-height: 0; overflow: hidden; background: rgba(var(--bs-secondary-bg-rgb), .78); }
.detail-pane { height: 210px; overflow: auto; padding: .65rem; }
.loading-line { display: flex; align-items: center; gap: .5rem; color: var(--bs-secondary-color); padding: .75rem; }
.muted-pane { color: var(--bs-secondary-color); }
.detail-table { white-space: nowrap; }
.general-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .6rem; }
.general-grid div { border: 1px solid var(--bs-border-color); border-radius: .6rem; padding: .5rem; background: var(--bs-body-bg); min-width: 0; }
.general-grid b { display: block; color: var(--bs-secondary-color); font-size: .72rem; text-transform: uppercase; }
.general-grid span { overflow-wrap: anywhere; }
.statusbar { display: flex; align-items: center; gap: 1rem; padding: 0 .75rem; overflow-x: auto; background: var(--bs-tertiary-bg); color: var(--bs-secondary-color); white-space: nowrap; }
.statusbar b { color: var(--bs-body-color); }
.status-limit { border: 1px solid var(--bs-border-color); background: rgba(var(--bs-secondary-bg-rgb), .9); color: var(--bs-secondary-color); border-radius: .45rem; padding: .12rem .5rem; white-space: nowrap; }
.status-limit:hover { color: var(--bs-body-color); background: var(--bs-secondary-bg); }
.ctx-menu { display: none; position: absolute; z-index: 5000; min-width: 200px; padding: .35rem; border: 1px solid var(--bs-border-color); border-radius: .6rem; background: var(--bs-body-bg); }
.ctx-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: var(--bs-body-color); padding: .42rem .55rem; border-radius: .4rem; }
.ctx-menu button:hover { background: var(--bs-secondary-bg); }
.ctx-menu .danger { color: var(--bs-danger); }
.ctx-menu hr { margin: .25rem 0; border-color: var(--bs-border-color); }
.profile-row { display: grid; grid-template-columns: 1fr auto; gap: .25rem .5rem; align-items: center; padding: .45rem; border: 1px solid var(--bs-border-color); border-radius: .6rem; margin-bottom: .45rem; background: rgba(var(--bs-secondary-bg-rgb), .58); }
.profile-row span { grid-column: 1 / 2; color: var(--bs-secondary-color); overflow-wrap: anywhere; }
.profile-form-actions { display: inline-flex; gap: .35rem; flex-wrap: wrap; }
.profile-actions { display: inline-flex; gap: .35rem; }
.profile-row.active { border-color: var(--bs-primary); background: var(--bs-primary-bg-subtle); }
.flag-icon { border-radius: 2px; box-shadow: 0 0 0 1px rgba(255,255,255,.12); }
.flag-code { color: var(--bs-secondary-color); margin-left: .25rem; }
.peer-actions { display: flex; align-items: center; gap: .25rem; flex-wrap: nowrap; }
.peer-actions .btn { display: inline-flex; align-items: center; gap: .25rem; border-radius: .35rem !important; }
.modal-content { background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: 14px; }
.modal-header, .modal-footer { background: rgba(var(--bs-secondary-bg-rgb), .82); border-color: var(--bs-border-color); }
.add-grid { display: grid; gap: .85rem; }
.magnet-box { min-height: 64px; resize: vertical; }
.upload-box, .surface-section { border: 1px solid var(--bs-border-color); background: rgba(var(--bs-secondary-bg-rgb), .5); border-radius: .75rem; padding: .75rem; }
.section-title { font-weight: 700; margin-bottom: .55rem; color: var(--bs-body-color); }
.preset-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .4rem; }
.toast-host { position: fixed; right: 14px; top: 70px; z-index: 8000; display: grid; gap: .4rem; }
.toast-item { padding: .45rem .65rem; border-radius: .55rem; box-shadow: 0 8px 25px rgba(0,0,0,.28); max-width: 360px; }
@media (max-width: 1100px) {
:root { --topbar: 88px; }
.topbar { align-items: flex-start; flex-wrap: wrap; }
.toolbar-left { flex: 1 1 100%; overflow: visible; flex-wrap: wrap; }
.toolbar-right { flex: 1 1 100%; justify-content: flex-end; }
.search { flex: 1 1 220px; width: auto; min-width: 160px; max-width: none; }
}
@media (max-width: 900px) {
:root { --sidebar: 0px; }
.sidebar { display: none; }
.general-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
:root { --topbar: 132px; }
.toolbar-right { width: 100%; justify-content: flex-start; flex-wrap: nowrap; gap: .35rem; }
.search { flex: 1 1 0; width: auto; min-width: 0; max-width: none; }
.preset-grid { grid-template-columns: 1fr 1fr; }
}
.preferences-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: .75rem;
}
.form-field { display: grid; gap: .3rem; }
.form-field > span { color: var(--bs-secondary-color); font-size: .78rem; font-weight: 700; text-transform: uppercase; }
@media (max-width: 640px) { .preferences-grid { grid-template-columns: 1fr; } }
/* Feature additions without changing the existing visual shell */
.date-compact {
white-space: nowrap;
}
.btn-xs {
--bs-btn-padding-y: .18rem;
--bs-btn-padding-x: .42rem;
--bs-btn-font-size: .78rem;
--bs-btn-border-radius: .35rem;
}
.nav-btn {
border-radius: .45rem !important;
margin: 0 !important;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .25rem;
}
.nav-btn + .nav-btn,
.torrent-action + .torrent-action { margin-left: .08rem !important; }
.path-list {
height: 360px;
overflow: auto;
border: 1px solid var(--bs-border-color);
border-radius: .6rem;
background: rgba(var(--bs-secondary-bg-rgb), .35);
}
.path-row {
display: flex;
align-items: center;
gap: .5rem;
padding: .42rem .6rem;
border-bottom: 1px solid var(--bs-border-color);
cursor: pointer;
}
.path-row:hover { background: var(--bs-primary-bg-subtle); color: var(--bs-primary-text-emphasis); }
.chips { display: flex; gap: .35rem; flex-wrap: wrap; }
.chip {
border: 1px solid var(--bs-border-color);
background: rgba(var(--bs-secondary-bg-rgb), .6);
color: var(--bs-body-color);
border-radius: 999px;
padding: .22rem .6rem;
font-size: .78rem;
}
.mobile-list { overflow: auto; padding: .55rem; background: var(--bs-body-bg); }
.mobile-card {
border: 1px solid var(--bs-border-color);
background: rgba(var(--bs-secondary-bg-rgb), .72);
border-radius: .75rem;
padding: .65rem;
margin-bottom: .55rem;
}
.mobile-card.selected { outline: 2px solid var(--bs-primary); }
.mobile-card .name { font-weight: 700; word-break: break-word; }
.mobile-actions { display: flex; gap: .35rem; margin-top: .45rem; }
#systemChart {
width: 140px;
height: 24px;
border: 1px solid var(--bs-border-color);
border-radius: .35rem;
background: rgba(var(--bs-secondary-bg-rgb), .85);
}
.badge-degraded { background: #f59e0b !important; color: #111 !important; }
body.mobile-mode .table-wrap { display: none !important; }
body.mobile-mode #mobileList { display: block !important; }
body.mobile-mode .content { grid-template-rows: 1fr 210px; }
body.mobile-mode .torrent-table { display: none; }
@media (max-width: 640px) {
.nav-btn span { display: none; }
}
/* Fixes: compact one-line progress cell and readable percent inside the bar. */
.torrent-table td:nth-child(5) { min-width: 92px; width: 110px; white-space: nowrap; }
.hidden-col{display:none!important}
.status-docs{margin-left:auto;color:inherit;text-decoration:none;font-weight:600;opacity:.9;white-space:nowrap}
.status-docs:hover{opacity:1;text-decoration:underline}
.column-check{padding:.35rem .5rem;border:1px solid var(--bs-border-color);border-radius:.5rem;background:var(--bs-body-bg)}
.label-filters .label-filter{font-size:.82rem;padding:.34rem .5rem;margin-bottom:.15rem}
.label-filters .label-filter i{opacity:.75;margin-right:.25rem}
.column-manager{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:.55rem}
.column-card{display:flex;align-items:center;gap:.5rem;padding:.55rem .65rem;border:1px solid var(--bs-border-color);border-radius:.7rem;background:rgba(var(--bs-secondary-bg-rgb),.45);cursor:pointer;user-select:none;transition:background .15s,border-color .15s,transform .15s}
.column-card:hover{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle)}
.column-card.active{border-color:rgba(var(--bs-primary-rgb),.55);background:var(--bs-primary-bg-subtle)}
.column-card input{margin:0}.column-card span{display:flex;gap:.45rem;align-items:center;font-weight:600}.column-card i{opacity:.72}
.path-row::before{content:'\f07b';font-family:'Font Awesome 6 Free';font-weight:900;color:var(--bs-warning)}
body.mobile-mode #mobileList{min-height:0;height:100%;overflow:auto;display:block!important}
body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{min-width:34px}
#toolSmart .form-label{font-size:.75rem;color:var(--bs-secondary-color);margin-bottom:.2rem}
.profile-form-grid{display:grid;grid-template-columns:1.1fr 2.1fr .55fr .75fr auto auto;gap:.5rem;align-items:center}
#toolSmart .btn{padding:.25rem .55rem;border-radius:.5rem;white-space:nowrap}
#toolSmart .row .d-flex{align-items:end;justify-content:flex-start}
#trafficHistoryChart{width:100%;height:420px;border:1px solid var(--bs-border-color);border-radius:.75rem;background:var(--bs-body-bg)}
@media (max-width: 992px){.profile-form-grid{grid-template-columns:1fr}.profile-form-grid .btn{width:100%}}
/* Requested fixes: stable charts, Smart Queue exceptions, label actions, mobile readability */
.history-grid{display:grid;grid-template-columns:1fr;gap:1rem}
.history-card{border:1px solid var(--bs-border-color);border-radius:.8rem;background:rgba(var(--bs-secondary-bg-rgb),.35);padding:.75rem;min-width:0;overflow:hidden}
.history-title{font-weight:700;font-size:.9rem;margin-bottom:.45rem;color:var(--bs-body-color)}
#trafficHistoryChart,#trafficSpeedChart{display:block;width:100%;height:420px;max-width:100%;border:0;border-radius:.55rem;background:var(--bs-body-bg)}
@media (min-width: 992px){.history-grid{grid-template-columns:1fr}}
.smart-actions{display:flex;align-items:center;gap:.45rem;flex-wrap:wrap}
.empty-mini{padding:.7rem .8rem;border:1px dashed var(--bs-border-color);border-radius:.7rem;color:var(--bs-secondary-color);background:rgba(var(--bs-secondary-bg-rgb),.35)}
.label-manager-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--bs-border-color);border-radius:.65rem;padding:.4rem .5rem;margin-bottom:.4rem;background:rgba(var(--bs-secondary-bg-rgb),.35)}
.tool-tab i{margin-right:.25rem;opacity:.82}
body.mobile-mode .content{display:grid!important;grid-template-rows:minmax(0,1fr)!important;min-height:0;overflow:hidden}
body.mobile-mode .details{display:none!important}
body.mobile-mode #mobileList{display:block!important;height:100%!important;min-height:220px;overflow:auto;position:relative;z-index:2;padding-bottom:1rem}
body.mobile-mode .main-grid{min-height:0;overflow:hidden}
@media (max-width:640px){.history-card{padding:.5rem}#trafficHistoryChart,#trafficSpeedChart{height:320px}.statusbar{font-size:.75rem;gap:.6rem}.mobile-list{padding:.45rem}.mobile-card{margin-bottom:.45rem}}
/* Requested fixes: clean progress, mobile auto list, pagers, rTorrent config, peers refresh */
.torrent-progress{height:16px;min-width:92px;position:relative;margin:0;overflow:hidden;background:rgba(var(--bs-secondary-bg-rgb),.8)!important}
.torrent-progress .progress-bar{min-width:0!important;position:relative;transition:width .25s ease,background-color .25s ease}
.torrent-progress>span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;line-height:1;color:var(--bs-body-color);text-shadow:none;white-space:nowrap;pointer-events:none}
.torrent-progress .progress-bar+span{color:var(--bs-body-color)}
body.mobile-mode #mobileList{display:block!important}
@media (max-width:700px){
body:not(.desktop-mode) .table-wrap{display:none!important}
body:not(.desktop-mode) #mobileList{display:block!important;min-height:260px;height:100%;overflow:auto}
body:not(.desktop-mode) .content{display:grid!important;grid-template-rows:minmax(0,1fr)!important;min-height:0;overflow:hidden}
body:not(.desktop-mode) .details{display:none!important}
}
.pager-row{display:flex;align-items:center;justify-content:flex-end;gap:.5rem}
.peers-refresh{display:flex;align-items:center;gap:.5rem;justify-content:flex-end;padding:.35rem .75rem;border-bottom:1px solid var(--bs-border-color);background:rgba(var(--bs-secondary-bg-rgb),.35)}
.peers-refresh select{width:auto;min-width:96px}
/* Mobile list: force visible on narrow screens even without manual toggle. */
@media (max-width: 900px) {
body:not(.modal-open) .table-wrap { display: none !important; }
body:not(.modal-open) #mobileList { display: block !important; height: 100% !important; min-height: 260px; overflow: auto; }
body:not(.modal-open) .content { display: grid !important; grid-template-rows: minmax(0,1fr) !important; min-height: 0; overflow: hidden; }
body:not(.modal-open) .details { display: none !important; }
}
.torrent-paused td{opacity:.82}
.torrent-paused .name{font-style:italic}
/* Mobile blank-view fix: sidebar disappears at 900px, so the mobile list must also be forced from 900px down. */
@media (max-width: 900px) {
.main-grid {
display: grid !important;
grid-template-columns: minmax(0, 1fr) !important;
min-height: 0 !important;
height: 100% !important;
overflow: hidden !important;
}
.sidebar { display: none !important; }
.content {
display: grid !important;
grid-template-rows: minmax(0, 1fr) !important;
min-height: 0 !important;
height: 100% !important;
overflow: hidden !important;
}
.table-wrap { display: none !important; }
#mobileList {
display: block !important;
height: 100% !important;
min-height: 0 !important;
overflow: auto !important;
position: relative !important;
z-index: 10 !important;
background: var(--bs-body-bg) !important;
padding: .55rem !important;
}
.details { display: none !important; }
.toolbar-right { width: 100% !important; min-width: 0 !important; flex-wrap: nowrap !important; gap: .35rem !important; }
.search { min-width: 0 !important; width: auto !important; flex: 1 1 0 !important; max-width: none !important; }
.mobile-speed-stats { display: inline-flex; }
}
@media (max-width: 640px) {
.toolbar-right { flex-wrap: nowrap !important; gap: .3rem !important; }
.search { min-width: 0 !important; width: auto !important; flex: 1 1 0 !important; max-width: none !important; }
.mobile-speed-stats { gap: .25rem; font-size: .66rem; }
}
.files-toolbar{display:flex;gap:.75rem;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:.5rem}
.file-priority-table .path{max-width:520px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-priority-table .file-priority{min-width:110px}
@media (max-width:900px){.files-toolbar{align-items:stretch}.files-toolbar .btn-group{display:grid;grid-template-columns:1fr;width:100%}.file-priority-table{font-size:.82rem}.file-priority-table .path{max-width:180px}}
.bulk-bar {
height: 38px;
display: flex;
align-items: center;
gap: .35rem;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
padding: .35rem .55rem;
border-bottom: 1px solid var(--bs-border-color);
background: rgba(var(--bs-secondary-bg-rgb), .95);
z-index: 4;
}
.bulk-bar.d-none { display: none !important; }
.bulk-bar span { color: var(--bs-secondary-color); margin-right: .3rem; white-space: nowrap; }
.bulk-bar .btn { white-space: nowrap; flex: 0 0 auto; }
.move-options {
border: 1px solid var(--bs-border-color);
border-radius: .6rem;
padding: .75rem;
background: var(--bs-tertiary-bg);
}
/* Stable main layout: bulk actions overlay the list area, details stay pinned at the bottom. */
.content {
position: relative;
grid-template-rows: minmax(0, 1fr) 255px !important;
}
#bulkBar { grid-row: 1; grid-column: 1; align-self: start; }
#tableWrap, #mobileList { grid-row: 1; grid-column: 1; min-height: 0; }
.details { grid-row: 2; grid-column: 1; min-height: 0; }
.bulk-bar:not(.d-none) + .table-wrap { padding-top: 38px; }
@media (max-width: 900px) {
.bulk-bar { gap: .3rem; }
}
.label-mini{font-size:.72rem;padding:.12rem .38rem;margin-right:.15rem}
.label-chip.active{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle);color:var(--bs-primary-text-emphasis)}
.label-selected{border-color:var(--bs-primary);background:var(--bs-primary-bg-subtle);color:var(--bs-primary-text-emphasis)}
.automation-form-grid { display:grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap:.5rem; align-items:center; }
.automation-row { display:flex; justify-content:space-between; gap:.75rem; align-items:center; padding:.55rem .65rem; border:1px solid var(--bs-border-color); border-radius:.6rem; margin-bottom:.45rem; background:var(--bs-body-bg); }
@media (max-width: 900px){ .automation-form-grid { grid-template-columns: 1fr; } }
.disk-status{display:inline-flex;align-items:center;gap:.35rem;min-width:110px}
.disk-status canvas{border-radius:999px;background:rgba(var(--bs-secondary-bg-rgb),.65)}
.disk-status.disk-warn b{color:var(--bs-warning)!important}
.system-chart{width:96px;height:24px;border-radius:.35rem;background:rgba(var(--bs-secondary-bg-rgb),.45)}
.torrent-progress.is-complete>span{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.35)}
.peer-progress{min-width:86px;width:96px}
.loading-center{justify-content:center;min-height:80px}
.loading-cell{padding:0!important}
.mobile-list .loading-center{min-height:160px}
/* Torrent warning and mobile controls */
.torrent-warning td { background: rgba(245, 158, 11, .075) !important; }
.torrent-warning:hover td { background: rgba(245, 158, 11, .11) !important; }
.torrent-warning.selected td { background: color-mix(in srgb, var(--bs-primary-bg-subtle) 82%, rgba(245, 158, 11, .16)) !important; }
.mobile-card.torrent-warning { background: rgba(245, 158, 11, .075); }
.mobile-card.torrent-warning.selected { background: color-mix(in srgb, var(--bs-primary-bg-subtle) 82%, rgba(245, 158, 11, .16)); }
.torrent-warning-icon { color: var(--bs-warning); margin-right: .2rem; }
.mobile-filter-bar {
display: none;
grid-row: 1;
grid-column: 1;
align-self: start;
position: sticky;
top: 0;
z-index: 12;
padding: .45rem .55rem;
border-bottom: 1px solid var(--bs-border-color);
background: rgba(var(--bs-body-bg-rgb), .96);
}
.mobile-filter-actions,
.mobile-filter-select-row {
display: flex;
align-items: center;
gap: .35rem;
}
.mobile-filter-actions { margin-bottom: .4rem; }
.mobile-filter-actions span { color: var(--bs-secondary-color); font-size: .78rem; white-space: nowrap; }
.mobile-filter-select-row label {
color: var(--bs-secondary-color);
font-size: .78rem;
white-space: nowrap;
}
.mobile-filter-select-row select {
min-width: 0;
flex: 1 1 auto;
}
body.mobile-mode .mobile-filter-bar { display: block !important; }
body.mobile-mode #mobileList { padding-top: 5.2rem !important; }
@media (max-width: 900px) {
#mobileFilterBar { display: block !important; }
#mobileList { padding-top: 5.2rem !important; }
.topbar .badge {
width: .72rem;
height: .72rem;
min-width: .72rem;
padding: 0 !important;
border-radius: 999px;
overflow: hidden;
color: transparent !important;
text-indent: -999px;
box-shadow: 0 0 0 1px rgba(255,255,255,.22);
}
.topbar .badge .spinner-border { display: none; }
}
/* rTorrent config */
.rt-config-grid {
display: grid;
gap: .6rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.rt-config-group {
grid-column: 1 / -1;
padding: .45rem .2rem .1rem;
border-bottom: 1px solid var(--bs-border-color);
color: var(--bs-primary-text-emphasis);
font-weight: 800;
}
.rt-config-note {
margin-bottom: .75rem;
}
.rt-config-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .75rem;
margin-bottom: .75rem;
}
.rt-config-row {
display: grid;
grid-template-columns: 1fr minmax(120px, 190px);
align-items: center;
gap: .6rem;
padding: .6rem;
border: 1px solid var(--bs-border-color);
border-radius: .7rem;
background: rgba(var(--bs-secondary-bg-rgb), .35);
}
.rt-config-row b {
font-size: .88rem;
}
.rt-config-row small {
display: block;
overflow-wrap: anywhere;
color: var(--bs-secondary-color);
font-size: .72rem;
}
.rt-config-row.disabled {
opacity: .58;
}
.rt-config-row.changed,
.rt-config-row.changed-live {
border-color: var(--bs-danger);
box-shadow: 0 0 0 .12rem rgba(220, 53, 69, .2);
}
.rt-config-value-note {
margin-top: .15rem;
}
.rt-config-output {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .82rem;
}
/* Tracker management */
.tracker-toolbar,
.tracker-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .45rem;
}
.tracker-toolbar {
justify-content: space-between;
margin-bottom: .55rem;
}
.tracker-url {
min-width: 240px;
max-width: 520px;
}
.tracker-message {
max-width: 360px;
white-space: normal;
word-break: break-word;
}
.tracker-url-text {
word-break: break-all;
}
/* Cleanup and app diagnostics */
.tool-note {
color: var(--bs-secondary-color);
font-size: .82rem;
}
.cleanup-grid,
.diag-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: .6rem;
}
.cleanup-card,
.diag-card {
padding: .65rem;
border: 1px solid var(--bs-border-color);
border-radius: .7rem;
background: rgba(var(--bs-secondary-bg-rgb), .35);
}
.cleanup-card b,
.diag-card b {
display: block;
margin-bottom: .2rem;
color: var(--bs-secondary-color);
font-size: .78rem;
}
.cleanup-card span,
.diag-card span {
font-weight: 700;
}
.cleanup-card small {
display: block;
margin-top: .2rem;
overflow-wrap: anywhere;
color: var(--bs-secondary-color);
}
.cleanup-actions {
display: flex;
flex-wrap: wrap;
gap: .5rem;
}
.diag-error {
border-color: rgba(var(--bs-danger-rgb), .45);
background: rgba(var(--bs-danger-rgb), .08);
}
.port-status {
display: inline-flex;
align-items: center;
gap: .3rem;
padding: .12rem .4rem;
border-radius: .45rem;
}
.port-ok {
background: rgba(34, 197, 94, .14);
color: var(--bs-success);
}
.port-bad {
background: rgba(239, 68, 68, .14);
color: var(--bs-danger);
}
.port-secondary {
background: rgba(148, 163, 184, .12);
color: var(--bs-secondary-color);
}
.limit-slider-panel {
padding: .65rem;
border: 1px solid var(--bs-border-color);
border-radius: .7rem;
background: rgba(var(--bs-secondary-bg-rgb), .32);
}
.limit-slider-row + .limit-slider-row {
margin-top: .65rem;
}
.limit-slider-row .form-label {
display: flex;
justify-content: space-between;
gap: .75rem;
margin-bottom: .25rem;
}
@media (max-width: 640px) {
#mobileToggle {
display: none !important;
}
.tracker-url {
min-width: 160px;
max-width: 230px;
}
.tracker-message {
max-width: 220px;
}
}
.text-compact {
display: inline-block;
max-width: 32rem;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
/* Operation status, mobile progress and separated preferences */
.torrent-operating td {
background: rgba(13, 202, 240, .085) !important;
}
.torrent-operating:hover td {
background: rgba(13, 202, 240, .13) !important;
}
.torrent-operating.selected td {
background: color-mix(in srgb, var(--bs-primary-bg-subtle) 78%, rgba(13, 202, 240, .18)) !important;
}
.mobile-card.torrent-operating {
background: rgba(13, 202, 240, .085);
border-color: rgba(13, 202, 240, .45);
}
.mobile-card.torrent-operating.selected {
background: color-mix(in srgb, var(--bs-primary-bg-subtle) 78%, rgba(13, 202, 240, .18));
}
.operation-status-badge {
color: #062c33;
}
.mobile-progress {
margin-top: .45rem;
}
.mobile-progress .torrent-progress {
width: 100%;
min-width: 0;
}
.preferences-sections {
display: grid;
gap: 1rem;
}
.preference-section {
border-left: .25rem solid var(--bs-primary);
}