Discover Sonarr episode downloads and keep live tracker updating
Magent CI/CD / verify (push) Canceled after 1m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-06 22:46:01 +12:00
parent bd668715a3
commit 625f9ad7f0
7 changed files with 157 additions and 10 deletions
+16 -1
View File
@@ -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")
+18 -1
View File
@@ -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)
+25
View File
@@ -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
+3 -1
View File
@@ -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}