diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index c690227..7f16502 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -984,9 +984,39 @@ def _tracker_int(value, default=None): return default +def _tracker_rows(c: ScgiRtorrentClient, torrent_hash: str) -> list[list]: + fields = ("t.url=", "t.is_enabled=", "t.scrape_complete=", "t.scrape_incomplete=", "t.scrape_downloaded=") + errors: list[str] = [] + for args in ((torrent_hash, "", *fields), ("", torrent_hash, *fields)): + try: + rows = c.call("t.multicall", *args) + return [list(r) for r in (rows or [])] + except Exception as exc: + errors.append(f"t.multicall{args[:2]}: {exc}") + # Note: Fallback keeps the sidebar tracker filter usable on rTorrent builds without t.multicall scrape fields. + total = _tracker_int(_safe_tracker_call(c, "d.tracker_size", torrent_hash, 0), 0) or 0 + rows: list[list] = [] + for index in range(max(0, total)): + target = _tracker_target(torrent_hash, index) + url = _safe_tracker_call(c, "t.url", target, "") + if not url: + for args in ((torrent_hash, index), ("", torrent_hash, index)): + try: + url = c.call("t.url", *args) + break + except Exception: + continue + if url: + enabled = _safe_tracker_call(c, "t.is_enabled", target, 1) + rows.append([url, enabled, None, None, None]) + if rows: + return rows + raise RuntimeError("Cannot read trackers: " + "; ".join(errors)) + + def torrent_trackers(profile: dict, torrent_hash: str) -> list[dict]: c = client_for(profile) - rows = c.t.multicall(torrent_hash, "", "t.url=", "t.is_enabled=", "t.scrape_complete=", "t.scrape_incomplete=", "t.scrape_downloaded=") + rows = _tracker_rows(c, torrent_hash) trackers = [] for idx, r in enumerate(rows): target = _tracker_target(torrent_hash, idx) diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 2b31ca4..4398030 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -21,6 +21,7 @@ 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"; @@ -240,13 +241,22 @@ const src=`https://${encodeURIComponent(domain).replace(/%2E/g,'.')}/favicon.ico`; return ``; } + function trackerFilterPlaceholder(){ + if(trackerSummaryStatus==='loading') return '
Loading trackers...
'; + if(trackerSummaryStatus==='error') return '
Tracker list unavailable
'; + if(hasTorrentSnapshot && torrents.size) return '
No trackers found
'; + return '
Waiting for torrents...
'; + } 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 filters are appended below status and label filters, using read-only tracker summary data. - box.innerHTML=trackers.length?`
Trackers
${trackers.map(t=>``).join('')}`:''; + // 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=>``).join('') + : trackerFilterPlaceholder(); + box.innerHTML=`
Trackers
${rows}`; bindSidebarFilterClicks(box); } async function refreshTrackerSummary(force=false){ @@ -254,14 +264,20 @@ const sig=`${hashes.length}:${hashes.slice(0,2000).join(',')}:${trackerFaviconsEnabled?1:0}`; if(!force && sig===trackerSummarySignature) return; trackerSummarySignature=sig; - if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[]}; renderTrackerFilters(); return; } + if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[]}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; } + trackerSummaryStatus='loading'; + renderTrackerFilters(); try{ - const j=await (await fetch(`/api/trackers/summary?limit=2000`)).json(); + const qs=new URLSearchParams({limit:'2000'}); + // Note: Browser sends currently visible torrent hashes, avoiding an empty cache race on the backend. + hashes.slice(0,2000).forEach(h=>qs.append('hash',h)); + const j=await (await fetch(`/api/trackers/summary?${qs.toString()}`)).json(); if(!j.ok) throw new Error(j.error||'Tracker summary failed'); trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[]}; + trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'empty'; renderTrackerFilters(); scheduleRender(true); - }catch(e){ console.warn('Tracker summary failed', e); } + }catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); } } function scheduleTrackerSummary(force=false){ clearTimeout(trackerSummaryTimer); diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 22ba1ad..88c0a21 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -892,6 +892,20 @@ body.mobile-mode .main-grid { display: none; } +.tracker-filter-empty { + align-items: center; + color: var(--bs-secondary-color); + display: flex; + font-size: 0.78rem; + gap: 0.35rem; + padding: 0.25rem 0.5rem; +} + +/* Note: Empty tracker state uses the same sidebar spacing as regular filter rows. */ +.tracker-filter-empty .spinner-border-xs { + height: 0.65rem; + width: 0.65rem; +} .column-manager { display: grid;