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