Discover Sonarr episode downloads and keep live tracker updating
This commit is contained in:
@@ -33,7 +33,22 @@ class SonarrClient(ApiClient):
|
|||||||
return await self.get("/api/v3/qualityprofile")
|
return await self.get("/api/v3/qualityprofile")
|
||||||
|
|
||||||
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
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]]:
|
async def get_indexers(self) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/indexer")
|
return await self.get("/api/v3/indexer")
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from ..db import (
|
|||||||
active_repair_request_ids,
|
active_repair_request_ids,
|
||||||
)
|
)
|
||||||
from ..services.media_repair import current_cycle_torrents
|
from ..services.media_repair import current_cycle_torrents
|
||||||
|
from ..services.download_labels import label_episode_downloads
|
||||||
from ..models import Snapshot, TriageResult, RequestType
|
from ..models import Snapshot, TriageResult, RequestType
|
||||||
from ..services.snapshot import (
|
from ..services.snapshot import (
|
||||||
_summarize_qbit,
|
_summarize_qbit,
|
||||||
@@ -2734,6 +2735,22 @@ async def get_download_progress(
|
|||||||
raise HTTPException(status_code=503, detail="qBittorrent is not configured")
|
raise HTTPException(status_code=503, detail="qBittorrent is not configured")
|
||||||
|
|
||||||
try:
|
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:
|
if hashes:
|
||||||
result = await qbittorrent.get_torrents_by_hashes("|".join(hashes))
|
result = await qbittorrent.get_torrents_by_hashes("|".join(hashes))
|
||||||
else:
|
else:
|
||||||
@@ -2742,7 +2759,7 @@ async def get_download_progress(
|
|||||||
logger.warning("Live qBittorrent progress failed request_id=%s: %s", request_id, exc)
|
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
|
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:
|
for torrent in torrents:
|
||||||
if isinstance(torrent, dict):
|
if isinstance(torrent, dict):
|
||||||
torrent["progressPercent"] = _torrent_progress(torrent)
|
torrent["progressPercent"] = _torrent_progress(torrent)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -31,6 +31,7 @@ from ..db import (
|
|||||||
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||||
from .collector_search import read_search_status
|
from .collector_search import read_search_status
|
||||||
from .media_repair import current_cycle_torrents, evaluate_media_repair
|
from .media_repair import current_cycle_torrents, evaluate_media_repair
|
||||||
|
from .download_labels import label_episode_downloads
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1431,12 +1432,13 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
try:
|
try:
|
||||||
if qbittorrent.configured():
|
if qbittorrent.configured():
|
||||||
if download_ids:
|
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 []
|
torrent_list = torrents if isinstance(torrents, list) else []
|
||||||
else:
|
else:
|
||||||
request_tag = f"magent-{request_id}"
|
request_tag = f"magent-{request_id}"
|
||||||
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
|
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
|
||||||
torrent_list = torrents if isinstance(torrents, list) else []
|
torrent_list = torrents if isinstance(torrents, list) else []
|
||||||
|
label_episode_downloads(torrent_list, arr_queue)
|
||||||
unfiltered_torrents = torrent_list
|
unfiltered_torrents = torrent_list
|
||||||
torrent_list = current_cycle_torrents(torrent_list, repair_cycle)
|
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}
|
discarded_hashes = {str(t.get("hash") or "").lower() for t in unfiltered_torrents if t not in torrent_list}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -445,12 +445,8 @@ export default function RequestTimelinePage() {
|
|||||||
|
|
||||||
const liveDownloadKey = useMemo(() => {
|
const liveDownloadKey = useMemo(() => {
|
||||||
const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download')
|
const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download')
|
||||||
if (!downloadStage?.visible || downloadStage.state !== 'active') return ''
|
if (!downloadStage || downloadStage.state === 'complete') return ''
|
||||||
return (downloadStage.torrents ?? [])
|
return 'discover-and-track'
|
||||||
.filter((torrent) => (torrentProgress(torrent) ?? 100) < 100)
|
|
||||||
.map((torrent) => String(torrent.hash ?? torrent.name ?? 'download'))
|
|
||||||
.sort()
|
|
||||||
.join('|')
|
|
||||||
}, [snapshot])
|
}, [snapshot])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -459,7 +455,7 @@ export default function RequestTimelinePage() {
|
|||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
let controller: AbortController | null = null
|
let controller: AbortController | null = null
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
if (!stopped) timer = window.setTimeout(() => void refresh(), 2_000)
|
if (!stopped) timer = window.setTimeout(() => void refresh(), 5_000)
|
||||||
}
|
}
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (document.visibilityState === 'hidden') {
|
if (document.visibilityState === 'hidden') {
|
||||||
@@ -890,6 +886,7 @@ export default function RequestTimelinePage() {
|
|||||||
const progress = torrentProgress(torrent)
|
const progress = torrentProgress(torrent)
|
||||||
return (
|
return (
|
||||||
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
|
<div className="request-torrent" key={torrent.hash ?? torrent.name}>
|
||||||
|
{torrent.episodeLabel && <small>{torrent.episodeLabel}</small>}
|
||||||
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : formatProgress(progress)}</span></div>
|
<div><strong>{torrent.name ?? 'Download'}</strong><span>{progress === null ? 'Progress unavailable' : formatProgress(progress)}</span></div>
|
||||||
{progress !== null && (
|
{progress !== null && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Browser regression: a missing-download card must discover two new episodes.
|
||||||
|
// All network data is mocked; no downloads are started.
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
|
||||||
|
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
|
||||||
|
;(async () => {
|
||||||
|
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 })
|
||||||
Reference in New Issue
Block a user