From 625f9ad7f075e8cb28120605b392d313afd62bca Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Sun, 6 Sep 2026 22:46:01 +1200 Subject: [PATCH] Discover Sonarr episode downloads and keep live tracker updating --- backend/app/clients/sonarr.py | 17 ++++++- backend/app/routers/requests.py | 19 +++++++- backend/app/services/download_labels.py | 25 ++++++++++ backend/app/services/snapshot.py | 4 +- backend/tests/test_tv_download_tracking.py | 54 ++++++++++++++++++++++ frontend/app/requests/[id]/page.tsx | 11 ++--- scripts/review_tv_downloads.cjs | 37 +++++++++++++++ 7 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 backend/app/services/download_labels.py create mode 100644 backend/tests/test_tv_download_tracking.py create mode 100644 scripts/review_tv_downloads.cjs diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index 2dbdaf2..e8d25d2 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -33,7 +33,22 @@ class SonarrClient(ApiClient): return await self.get("/api/v3/qualityprofile") async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]: - return await self.get("/api/v3/queue", params={"seriesId": series_id}) + records = [] + page = 1 + while True: + result = await self.get("/api/v3/queue", params={ + "seriesIds": series_id, "includeEpisode": "true", + "page": page, "pageSize": 100, + }) + if not isinstance(result, dict) or not isinstance(result.get("records"), list): + raise ValueError("Sonarr returned an invalid queue") + batch = result["records"] + records.extend(batch) + if not batch or len(records) >= int(result.get("totalRecords", len(records))): + return {**result, "records": records, "totalRecords": len(records)} + page += 1 + if page > 100: + raise ValueError("Sonarr queue exceeded the safe paging limit") async def get_indexers(self) -> Optional[Dict[str, Any]]: return await self.get("/api/v3/indexer") diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 0d690bf..dee7961 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -56,6 +56,7 @@ from ..db import ( active_repair_request_ids, ) from ..services.media_repair import current_cycle_torrents +from ..services.download_labels import label_episode_downloads from ..models import Snapshot, TriageResult, RequestType from ..services.snapshot import ( _summarize_qbit, @@ -2734,6 +2735,22 @@ async def get_download_progress( raise HTTPException(status_code=503, detail="qBittorrent is not configured") try: + # Discover new jobs from the collector, not only yesterday's hashes or + # legacy Magent tags. Sonarr-owned downloads do not have those tags. + queue = None + request = await asyncio.to_thread(get_request_cache_payload, int(request_id)) + if not isinstance(request, dict) and seerr.configured(): + request = await seerr.get_request(request_id) + media = (request or {}).get("media") or {} + if (request or {}).get("type") == "tv" and media.get("tvdbId"): + collector = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) + items = await collector.get_series_by_tvdb_id(int(media["tvdbId"])) + item = items[0] if isinstance(items, list) and items else None + if item and item.get("id"): + queue = await collector.get_queue(int(item["id"])) + queue = {**queue, "records": [r for r in _queue_records(queue) if r.get("seriesId") == item["id"]]} + hashes.extend(_download_ids(_queue_records(queue))) + hashes = list(dict.fromkeys(h.strip().lower() for h in hashes if h.strip())) if hashes: result = await qbittorrent.get_torrents_by_hashes("|".join(hashes)) else: @@ -2742,7 +2759,7 @@ async def get_download_progress( logger.warning("Live qBittorrent progress failed request_id=%s: %s", request_id, exc) raise HTTPException(status_code=502, detail="Live download progress is temporarily unavailable") from exc - torrents = current_cycle_torrents(result, cycle) + torrents = label_episode_downloads(current_cycle_torrents(result, cycle), queue) for torrent in torrents: if isinstance(torrent, dict): torrent["progressPercent"] = _torrent_progress(torrent) diff --git a/backend/app/services/download_labels.py b/backend/app/services/download_labels.py new file mode 100644 index 0000000..491dd42 --- /dev/null +++ b/backend/app/services/download_labels.py @@ -0,0 +1,25 @@ +from typing import Any + + +def label_episode_downloads(torrents: list[dict], queue: Any) -> list[dict]: + """Join by collector download ID, never by fuzzy title matching. + + A pack shares one transfer percentage; do not pretend its episodes have + individually measured progress. + """ + records = queue.get("records", []) if isinstance(queue, dict) else queue + labels: dict[str, set[str]] = {} + for row in records if isinstance(records, list) else []: + episode = row.get("episode") or {} + season, number = episode.get("seasonNumber"), episode.get("episodeNumber") + if isinstance(season, int) and isinstance(number, int): + key = str(row.get("downloadId") or "").lower() + labels.setdefault(key, set()).add(f"S{season:02d}E{number:02d}") + for torrent in torrents: + episodes = sorted(labels.get(str(torrent.get("hash") or "").lower(), set())) + torrent["episodeLabels"] = episodes + torrent["episodeLabel"] = ( + " · ".join(episodes) + (" — shared download progress" if len(episodes) > 1 else "") + if episodes else None + ) + return torrents diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index 4dea292..bf9bb26 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -31,6 +31,7 @@ from ..db import ( from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop from .collector_search import read_search_status from .media_repair import current_cycle_torrents, evaluate_media_repair +from .download_labels import label_episode_downloads logger = logging.getLogger(__name__) @@ -1431,12 +1432,13 @@ async def build_snapshot(request_id: str) -> Snapshot: try: if qbittorrent.configured(): if download_ids: - torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids)) + torrents = await qbittorrent.get_torrents_by_hashes("|".join(h.lower() for h in download_ids)) torrent_list = torrents if isinstance(torrents, list) else [] else: request_tag = f"magent-{request_id}" torrents = await qbittorrent.get_torrents_by_tag(request_tag) torrent_list = torrents if isinstance(torrents, list) else [] + label_episode_downloads(torrent_list, arr_queue) unfiltered_torrents = torrent_list torrent_list = current_cycle_torrents(torrent_list, repair_cycle) discarded_hashes = {str(t.get("hash") or "").lower() for t in unfiltered_torrents if t not in torrent_list} diff --git a/backend/tests/test_tv_download_tracking.py b/backend/tests/test_tv_download_tracking.py new file mode 100644 index 0000000..34c82a6 --- /dev/null +++ b/backend/tests/test_tv_download_tracking.py @@ -0,0 +1,54 @@ +import unittest +from unittest.mock import AsyncMock, patch +from types import SimpleNamespace +from backend.app.clients.sonarr import SonarrClient +from backend.app.services.download_labels import label_episode_downloads +from backend.app.routers import requests + + +class TvDownloadTrackingTests(unittest.IsolatedAsyncioTestCase): + async def test_queue_paginates_with_correct_filter(self): + client = SonarrClient('http://sonarr.test', 'test') + with patch.object(client, 'get', new=AsyncMock(side_effect=[ + {'records': [{'id': 1}], 'totalRecords': 2}, + {'records': [{'id': 2}], 'totalRecords': 2}, + ])) as get: + result = await client.get_queue(42) + self.assertEqual(len(result['records']), 2) + self.assertEqual(get.call_args_list[0].kwargs['params']['seriesIds'], 42) + self.assertEqual(get.call_args_list[1].kwargs['params']['page'], 2) + self.assertEqual(get.call_args.kwargs['params']['includeEpisode'], 'true') + + async def test_live_poll_discovers_two_unseen_episode_downloads(self): + runtime = SimpleNamespace(jellyseerr_base_url=None, jellyseerr_api_key=None, + sonarr_base_url='http://sonarr.test', sonarr_api_key='test', + qbittorrent_base_url='http://qbit.test', qbittorrent_username='test', qbittorrent_password='test') + queue = {'records': [ + {'seriesId': 42, 'downloadId': 'ABC', 'episode': {'seasonNumber': 5, 'episodeNumber': 9}}, + {'seriesId': 42, 'downloadId': 'DEF', 'episode': {'seasonNumber': 5, 'episodeNumber': 10}}, + {'seriesId': 99, 'downloadId': 'OTHER'}, + ]} + with patch.object(requests, 'get_runtime_settings', return_value=runtime), \ + patch.object(requests, 'get_request_repairs', return_value=[]), \ + patch.object(requests, 'get_request_download_evidence', return_value={'observed': True, 'torrents': []}), \ + patch.object(requests, 'get_request_cache_payload', return_value={'type': 'tv', 'media': {'tvdbId': 123}}), \ + patch.object(requests.SonarrClient, 'get_series_by_tvdb_id', new=AsyncMock(return_value=[{'id': 42}])), \ + patch.object(requests.SonarrClient, 'get_queue', new=AsyncMock(return_value=queue)), \ + patch.object(requests.QBittorrentClient, 'get_torrents_by_hashes', new=AsyncMock(return_value=[ + {'hash': 'abc', 'progress': .25, 'state': 'downloading'}, + {'hash': 'def', 'progress': .5, 'state': 'downloading'}, + ])) as torrents: + result = await requests.get_download_progress('12', {'username': 'viewer', 'role': 'user'}) + torrents.assert_awaited_once_with('abc|def') + self.assertEqual(result['state'], 'downloading') + self.assertEqual(result['torrents'][0]['episodeLabel'], 'S05E09') + self.assertEqual(result['torrents'][1]['progressPercent'], 50) + + def test_pack_does_not_claim_individual_episode_progress(self): + rows = [{'downloadId': 'PACK', 'episode': {'seasonNumber': 1, 'episodeNumber': n}} for n in [1, 2, 2]] + result = label_episode_downloads([{'hash': 'pack'}], rows) + self.assertEqual(result[0]['episodeLabel'], 'S01E01 · S01E02 — shared download progress') + + +if __name__ == '__main__': + unittest.main() diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index b1aea40..410227b 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -445,12 +445,8 @@ export default function RequestTimelinePage() { const liveDownloadKey = useMemo(() => { const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download') - if (!downloadStage?.visible || downloadStage.state !== 'active') return '' - return (downloadStage.torrents ?? []) - .filter((torrent) => (torrentProgress(torrent) ?? 100) < 100) - .map((torrent) => String(torrent.hash ?? torrent.name ?? 'download')) - .sort() - .join('|') + if (!downloadStage || downloadStage.state === 'complete') return '' + return 'discover-and-track' }, [snapshot]) useEffect(() => { @@ -459,7 +455,7 @@ export default function RequestTimelinePage() { let timer: number | undefined let controller: AbortController | null = null const schedule = () => { - if (!stopped) timer = window.setTimeout(() => void refresh(), 2_000) + if (!stopped) timer = window.setTimeout(() => void refresh(), 5_000) } const refresh = async () => { if (document.visibilityState === 'hidden') { @@ -890,6 +886,7 @@ export default function RequestTimelinePage() { const progress = torrentProgress(torrent) return (
+ {torrent.episodeLabel && {torrent.episodeLabel}}
{torrent.name ?? 'Download'}{progress === null ? 'Progress unavailable' : formatProgress(progress)}
{progress !== null && (
{ + const browser = await chromium.launch({ headless: true }) + try { + const context = await browser.newContext() + await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]) + let polls = 0 + await context.route('**/api/**', route => { + const path = new URL(route.request().url()).pathname + let json = { services: [] } + if (path.endsWith('/auth/me')) json = { username: 'member', role: 'user' } + if (path.endsWith('/snapshot')) json = { request_id: '12', title: 'TV tracker', request_type: 'tv', state: 'ADDED_TO_ARR', timeline: [], actions: [], presentation: { + status: { label: 'In the library queue' }, + pipeline: [{ id: 'download', label: 'Download', state: 'attention', visible: true, summary: 'Previous download missing', torrents: [] }], + } } + if (path.endsWith('/download-progress')) { + polls++ + json = { request_id: '12', state: 'downloading', summary: 'Downloading (2 active).', visible: true, + torrents: [9, 10].map(n => ({ hash: String(n), name: `Episode ${n}`, episodeLabel: `S05E${String(n).padStart(2, '0')}`, progress: polls > 1 ? .6 : .25 })) } + } + return route.fulfill({ json }) + }) + const page = await context.newPage() + await page.goto(base + '/requests/12') + await page.getByText('S05E09', { exact: true }).waitFor() + await page.getByText('S05E10', { exact: true }).waitFor() + assert.equal(await page.locator('.request-torrent').count(), 2) + await page.getByText('60% complete', { exact: true }).first().waitFor({ timeout: 10000 }) + await page.setViewportSize({ width: 390, height: 844 }) + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)) + console.log('PASS: missing tracker discovers two episodes and refreshes progress; mobile has no overflow.') + } finally { await browser.close() } +})().catch(error => { console.error(error); process.exitCode = 1 })