Files
pyTorrent/pytorrent/static/js/torrents.js
Mateusz Gruszczyński 9caa155324 post-check
2026-05-24 11:04:42 +02:00

2 lines
31 KiB
JavaScript

export const torrentsSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='post_check') return t.status==='Post-check' || !!t.post_check;\n if(type==='stopped') return !t.state && !isChecking(t) && t.status!=='Post-check' && !t.post_check;\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`<span class=\"filter-count\">${esc(bucket.count||0)}</span>${meta?`<span class=\"filter-meta\">${esc(meta)}</span>`:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentWarning(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:av<bv?-1:0)*sortState.dir;\n }\n let av=a[k], bv=b[k];\n if(typeof av==='string'||typeof bv==='string') return String(av||'').localeCompare(String(bv||''))*sortState.dir;\n return ((Number(av||0)>Number(bv||0))?1:(Number(av||0)<Number(bv||0)?-1:0))*sortState.dir;\n }\n 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>\"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `<span class=\"column-title\">${esc(base)}${sortIcon(th.dataset.sort)}</span>`;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n 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('')}`:'';\n bindSidebarFilterClicks(box);\n }\n function trackerFavicon(tracker){\n const domain=typeof tracker==='string'?tracker:(tracker?.domain||'');\n if(!trackerFaviconsEnabled || !domain) return '<i class=\"fa-solid fa-bullseye\"></i>';\n // Note: Normal rendering must use cached/static URLs only. Avoid refresh=1 here, otherwise scroll-triggered paints can re-warm icons repeatedly.\n const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}`;\n const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback;\n return `<img class=\"tracker-favicon\" src=\"${esc(src)}\" alt=\"\" loading=\"lazy\" data-fallback-src=\"${esc(fallback)}\" onerror=\"if(this.dataset.retry!=='1'){this.dataset.retry='1';this.src=this.dataset.fallbackSrc;}else{this.classList.add('d-none')}\"><i class=\"fa-solid fa-bullseye tracker-fallback-icon\"></i>`;\n }\n function trackerFilterPlaceholder(){\n if(trackerSummaryStatus==='loading') return '<div class=\"tracker-filter-empty\"><span class=\"spinner-border spinner-border-xs\"></span> Loading cached trackers...</div>';\n if(trackerSummaryStatus==='error') return '<div class=\"tracker-filter-empty text-warning\"><i class=\"fa-solid fa-triangle-exclamation\"></i> Tracker list unavailable</div>';\n if(Number(trackerSummary.pending||0)) return `<div class=\"tracker-filter-empty\"><span class=\"spinner-border spinner-border-xs\"></span> Tracker cache: ${esc(trackerSummary.cached||0)}/${esc(trackerSummary.scanned||0)}</div>`;\n if(hasTorrentSnapshot && torrents.size) return '<div class=\"tracker-filter-empty\">No trackers found</div>';\n return '<div class=\"tracker-filter-empty\">Waiting for torrents...</div>';\n }\n function renderTrackerFilters(force=false){\n const box=$('trackerFilters');\n if(!box) return;\n const trackers=trackerSummary.trackers || [];\n // Note: Keep the selected tracker while the async summary is loading or temporarily incomplete; otherwise sorting can reset mobile scope to All trackers.\n if(activeTrackerFilter && trackerSummaryStatus==='ready' && trackers.length && !trackers.some(t=>t.domain===activeTrackerFilter)) activeTrackerFilter='';\n const sig=[\n trackerSummaryStatus,\n trackerFaviconsEnabled ? 1 : 0,\n trackerSummary.pending || 0,\n trackerSummary.cached || 0,\n trackerSummary.scanned || 0,\n trackers.map(t=>`${t.domain}:${t.count||0}:${t.favicon_url||''}`).join('|')\n ].join('::');\n if(!force && sig===lastTrackerFiltersSignature){ syncFilterButtons(); return; }\n lastTrackerFiltersSignature=sig;\n // Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.\n const rows=trackers.length\n ? `<button class=\"filter tracker-filter tracker-filter-all ${!activeTrackerFilter?'active':''}\" data-filter=\"all\" data-tracker-scope=\"all\"><span><i class=\"fa-solid fa-globe\"></i> All trackers</span><span>${esc(torrents.size)}</span></button>` + trackers.map(t=>`<button class=\"filter tracker-filter ${activeTrackerFilter===t.domain?'active':''}\" data-filter=\"tracker:${esc(t.domain)}\"><span>${trackerFavicon(t)} ${esc(t.domain)}</span><span>${esc(t.count||0)}</span></button>`).join('')\n : trackerFilterPlaceholder();\n box.innerHTML=`<div class=\"small text-muted px-2 mb-1\">Trackers</div>${rows}`;\n bindSidebarFilterClicks(box);\n }\n async function refreshTrackerSummary(force=false){\n const hashes=[...torrents.keys()].sort();\n const sig=`${hashes.length}:${hashes[0]||''}:${hashes[hashes.length-1]||''}:${trackerFaviconsEnabled?1:0}`;\n if(!force && sig===trackerSummarySignature && !Number(trackerSummary.pending||0)) return;\n trackerSummarySignature=sig;\n if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; }\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'loading';\n renderTrackerFilters();\n try{\n // Note: Do not send 13k hashes in the URL; the backend uses a local snapshot and reads the cache in small chunks.\n const j=await (await fetch('/api/trackers/summary?scan_limit=0&warm=1&bg_limit=80')).json();\n if(!j.ok && !j.summary) throw new Error(j.error||'Tracker summary failed');\n trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0};\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':Number(trackerSummary.pending||0)?'empty':'empty';\n renderTrackerFilters();\n scheduleRender(true);\n if(Number(trackerSummary.pending||0)>0){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(true).catch(()=>{}), 5000);\n }\n }catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); }\n }\n function scheduleTrackerSummary(force=false){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(force).catch(()=>{}), force?50:600);\n }\n function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }\n function visibleColumnKeys(){ return ['select', ...COLUMN_DEFS.map(([key])=>key)].filter(key => key === 'select' || !hiddenColumns.has(key)); }\n function applyColumnWidths(){\n // Note: Widths are applied to headers and virtualized body rows, keeping all cells aligned after live renders.\n const table = document.querySelector('.torrent-table');\n if(!table) return;\n let total = 0;\n visibleColumnKeys().forEach(key => { total += columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120; });\n table.style.width = `${total}px`;\n table.style.minWidth = `${total}px`;\n document.querySelectorAll('.torrent-table [data-col]').forEach(el=>{\n const key = el.dataset.col;\n const width = columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120;\n el.style.width = `${width}px`;\n el.style.minWidth = `${width}px`;\n el.style.maxWidth = `${width}px`;\n });\n }\n function applyColumnVisibility(){\n document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col)));\n applyColumnWidths();\n }\n function saveColumnWidthsPreference(){\n saveBrowserViewPrefs({columnWidths});\n savePreferencePatch({table_columns_json:columnPrefsPayload()}, 300);\n }\n function setupColumnResizers(){\n document.querySelectorAll('.torrent-table thead th[data-col]').forEach(th=>{\n const key = th.dataset.col;\n if(!key || key === 'select' || th.querySelector('.column-resize-handle')) return;\n const handle = document.createElement('span');\n handle.className = 'column-resize-handle';\n handle.title = 'Drag to resize column';\n handle.setAttribute('aria-hidden', 'true');\n th.appendChild(handle);\n let startX = 0, startWidth = 0, dragged = false;\n const onMove = (event) => {\n dragged = true;\n columnWidths[key] = clampNumber(startWidth + event.clientX - startX, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, startWidth);\n applyColumnWidths();\n };\n const onUp = () => {\n document.body.classList.remove('resizing-columns');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n if(dragged) saveColumnWidthsPreference();\n };\n handle.addEventListener('pointerdown', event=>{\n event.preventDefault();\n event.stopPropagation();\n dragged = false;\n startX = event.clientX;\n startWidth = columnWidths[key] || th.getBoundingClientRect().width || DEFAULT_COLUMN_WIDTHS[key] || 120;\n document.body.classList.add('resizing-columns');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n handle.addEventListener('click', event=>event.stopPropagation());\n });\n }\n function syncActiveFilterSelection(){ syncFilterButtons(); }\n function actionLabel(action){\n 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'};\n return labels[action] || `Working: ${action}`;\n }\n function actionIcon(action){\n 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';\n }\n function markTorrentOperation(hashes, action, jobId, state='queued'){\n const label=actionLabel(action);\n [...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));\n scheduleRender(true);\n }\n function markQueuedJobs(response, fallbackHashes, action){\n // Note: Supports API responses that split one large user action into multiple queued bulk parts.\n const jobs=Array.isArray(response?.jobs)?response.jobs:[];\n if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; }\n markTorrentOperation(fallbackHashes,action,response?.job_id,'queued');\n }\n function clearJobOperation(jobId, hashes=[]){\n if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }\n (hashes||[]).forEach(hash=>activeOperations.delete(hash));\n scheduleRender(true);\n }\n function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }\n function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n 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>`; }\n 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; }\n function torrentNameIcon(t){ const m=statusMeta(t); return `<i class=\"fa-solid ${m.icon} ${m.color}\"></i>`; }\n function boolCell(value){ return Number(value||0) ? '<span class=\"badge text-bg-success\">yes</span>' : '<span class=\"badge text-bg-secondary\">no</span>'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>`<span class=\"chip label-mini\"><i class=\"fa-solid fa-tag\"></i> ${esc(l)}</span>`).join(' ');\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\\n');\n return `<tr data-hash=\"${esc(t.hash)}\" class=\"${classes}\">`+\n `<td data-col=\"select\" class=\"sel\"><input class=\"row-check\" type=\"checkbox\" ${selected.has(t.hash)?'checked':''}></td>`+\n `<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>`+\n `<td data-col=\"status\">${statusBadge(t)}</td>`+\n `<td data-col=\"size\">${esc(t.size_h)}</td>`+\n `<td data-col=\"progress\">${progress(t)}</td>`+\n `<td data-col=\"down_rate\">${esc(t.down_rate_h)}</td>`+\n `<td data-col=\"up_rate\">${esc(t.up_rate_h)}</td>`+\n `<td data-col=\"eta\">${esc(t.eta_h||\"-\")}</td>`+\n `<td data-col=\"seeds\">${esc(t.seeds)}</td>`+\n `<td data-col=\"peers\">${esc(t.peers)}</td>`+\n `<td data-col=\"ratio\">${esc(t.ratio)}</td>`+\n `<td data-col=\"path\" class=\"path\" title=\"${esc(t.path)}\">${esc(t.path)}</td>`+\n `<td data-col=\"label\">${labels||'<span class=\"text-muted\">-</span>'}</td>`+\n `<td data-col=\"ratio_group\">${esc(t.ratio_group||'')}</td>`+\n `<td data-col=\"down_total\">${esc(t.down_total_h||'-')}</td>`+\n `<td data-col=\"to_download\">${esc(t.to_download_h||'-')}</td>`+\n `<td data-col=\"up_total\">${esc(t.up_total_h||'-')}</td>`+\n `<td data-col=\"created\">${esc(formatDateTime(t.created))}</td>`+\n `<td data-col=\"priority\">${esc(t.priority ?? '-')}</td>`+\n `<td data-col=\"state\">${boolCell(t.state)}</td>`+\n `<td data-col=\"active\">${boolCell(t.active)}</td>`+\n `<td data-col=\"complete\">${boolCell(t.complete)}</td>`+\n `<td data-col=\"hashing\">${esc(t.hashing ?? 0)}</td>`+\n `<td data-col=\"message\" class=\"message\" title=\"${esc(t.message||'')}\">${compactCell(t.message||'', 80)}</td>`+\n `<td data-col=\"hash\"><code>${esc(t.hash||'')}</code></td>`+\n `</tr>`;\n }\n\n\n\n\n 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(rows); 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(' '); const lines=mobileInfoLines(t); 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>${lines.primary?`<div class=\"small text-muted\">${lines.primary}</div>`:''}${lines.secondary?`<div class=\"small\">${lines.secondary}</div>`:''}${mobileColumns.path?`<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><button class=\"btn btn-xs btn-outline-danger\" data-action=\"remove\" title=\"Remove torrent\"><i class=\"fa-solid fa-trash-can\"></i></button></div>${mobileColumns.progress?`<div class=\"mobile-progress\">${progress(t)}</div>`:''}</div>`; }).join('') || (hasTorrentSnapshot ? `<div class=\"empty\">No torrents.</div>` : loadingMarkup('Loading torrents...')); }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`<tr><td colspan=\"${torrentColumnSpan()}\" 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}:${activeTrackerFilter}:${$('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=\"${torrentColumnSpan()}\" style=\"height:${top}px\"></td></tr>`:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?`<tr class=\"virtual-spacer\"><td colspan=\"${torrentColumnSpan()}\" style=\"height:${bottom}px\"></td></tr>`:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n 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})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n const show=selected.size>1;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n 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; }\n";