2 lines
7.8 KiB
JavaScript
2 lines
7.8 KiB
JavaScript
export const smartViewsSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentErrorLog(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction isPostCheckTorrent(t){ return t.status === 'Post-check' || !!t.post_check; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !isPostCheckTorrent(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const errorLog=torrentErrorLog(t);\n if(view==='smart:needs_attention') return !!errorLog || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentErrorLog(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>`<button class=\"health-row\" type=\"button\" data-hash=\"${esc(t.hash)}\"><span>${esc(t.name||'-')}</span><small>${esc(t.status||'-')} · ${esc(t.size_h||'-')} · DL ${esc(t.down_rate_h||'0 B/s')} · Ratio ${esc(t.ratio??'-')}</small></button>`).join('');\n return `<section class=\"health-card\"><div class=\"health-card-head\"><b>${esc(title)}</b><span class=\"badge text-bg-secondary\">${esc(rows.length)}</span></div><small>${esc(note)}</small><div class=\"health-list\">${sample||'<span class=\"empty-mini\">No items.</span>'}</div></section>`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent error log.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`<div class=\"column-manager-tabs\"><ul class=\"nav nav-pills\">${panes.map(p=>`<li class=\"nav-item\"><button class=\"nav-link ${p[0]===active?'active':''}\" type=\"button\" data-health-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>${panes.map(p=>`<div class=\"health-pane ${p[0]===active?'':'d-none'}\" data-health-panel=\"${p[0]}\"><div class=\"health-dashboard-grid\">${p[2]}</div></div>`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n // Note: The hint makes the card action explicit without changing existing filter behavior.\n box.innerHTML=`<div class=\"tool-note mb-2\"><i class=\"fa-solid fa-circle-info\"></i> Click any block to open the torrent list view filtered by that Smart View.</div><div class=\"smart-view-grid\">${SMART_VIEW_DEFS.map(([key,label,note])=>`<button class=\"smart-view-card ${activeFilter===key?'active':''}\" data-filter=\"${esc(key)}\" type=\"button\"><b>${esc(label)}</b><small>${esc(note)}</small><span>${esc(rows.filter(t=>smartViewVisible(t,key)).length)} torrents</span></button>`).join('')}</div>`;\n}\n";
|