Add live download progress updates
Magent CI/CD / verify (push) Successful in 12m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s

This commit is contained in:
2026-08-29 20:52:49 +12:00
parent 655e2f8158
commit 96fc43365f
5 changed files with 242 additions and 35 deletions
+74 -1
View File
@@ -47,9 +47,15 @@ from ..db import (
is_seerr_media_failure_suppressed,
record_seerr_media_failure,
clear_seerr_media_failure,
get_request_download_evidence,
)
from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import build_snapshot, jellyfin_item_matches_request
from ..services.snapshot import (
_summarize_qbit,
_torrent_progress,
build_snapshot,
jellyfin_item_matches_request,
)
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
@@ -1644,6 +1650,73 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
return _filter_snapshot_actions_for_user(snapshot, user)
@router.get("/{request_id}/download-progress")
async def get_download_progress(
request_id: str, user: Dict[str, str] = Depends(get_current_user)
) -> Dict[str, Any]:
"""Return a lightweight qBittorrent update for an open request page."""
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
evidence = await asyncio.to_thread(get_request_download_evidence, request_id, 20)
historical_torrents = evidence.get("torrents") if isinstance(evidence, dict) else []
hashes: List[str] = []
if isinstance(historical_torrents, list):
hashes = list(
dict.fromkeys(
str(torrent.get("hash") or "").strip()
for torrent in historical_torrents
if isinstance(torrent, dict) and torrent.get("hash")
)
)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
if not qbittorrent.configured():
raise HTTPException(status_code=503, detail="qBittorrent is not configured")
try:
if hashes:
result = await qbittorrent.get_torrents_by_hashes("|".join(hashes))
else:
result = await qbittorrent.get_torrents_by_tag(f"magent-{request_id}")
except Exception as 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
torrents = result if isinstance(result, list) else []
for torrent in torrents:
if isinstance(torrent, dict):
torrent["progressPercent"] = _torrent_progress(torrent)
if torrents:
summary = _summarize_qbit(torrents)
state = str(summary.get("state") or "idle")
message = str(summary.get("message") or "Download found in qBittorrent.")
elif evidence.get("observed"):
state = "missing"
message = "The previous download is no longer visible in qBittorrent."
else:
state = "not_started"
message = "No download attempt has been observed."
return {
"request_id": request_id,
"state": state,
"summary": message,
"torrents": torrents,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
@router.get("/recent")
async def recent_requests(
take: int = 6,
+3 -3
View File
@@ -382,14 +382,14 @@ def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[st
return f"https://image.tmdb.org/t/p/{size}{path}"
def _torrent_progress(torrent: Dict[str, Any]) -> Optional[int]:
def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]:
progress = torrent.get("progress")
try:
numeric = float(progress)
except (TypeError, ValueError):
numeric = -1
if 0 <= numeric <= 1:
return round(numeric * 100)
return round(numeric * 100, 1)
try:
size = float(torrent.get("size"))
amount_left = float(torrent.get("amount_left"))
@@ -397,7 +397,7 @@ def _torrent_progress(torrent: Dict[str, Any]) -> Optional[int]:
return None
if size <= 0:
return None
return max(0, min(100, round(((size - amount_left) / size) * 100)))
return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
def _build_presentation(
+37 -1
View File
@@ -19,7 +19,7 @@ from backend.app.routers import site as site_router
from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset
from backend.app.services.snapshot import _build_presentation, _episode_availability
from backend.app.services.snapshot import _build_presentation, _episode_availability, _torrent_progress
def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request:
@@ -173,6 +173,9 @@ class RequestCacheTests(unittest.TestCase):
class RequestPresentationTests(unittest.TestCase):
def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None:
self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
def test_episode_availability_counts_only_aired_monitored_episodes(self) -> None:
episodes = [
{"seasonNumber": 1, "episodeNumber": 1, "monitored": True, "hasFile": True},
@@ -226,6 +229,39 @@ class RequestPresentationTests(unittest.TestCase):
self.assertEqual(download_stage["summary"], "No download attempt yet")
class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase):
async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None:
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
qbittorrent_base_url="http://qbittorrent.test",
qbittorrent_username="magent",
qbittorrent_password="secret",
)
evidence = {
"observed": True,
"torrents": [{"hash": "abc123", "progress": 0.12}],
}
current = [{"hash": "abc123", "name": "Example", "progress": 0.1344, "state": "downloading"}]
with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(
requests_router,
"get_request_download_evidence",
return_value=evidence,
), patch.object(
requests_router.QBittorrentClient,
"get_torrents_by_hashes",
new=AsyncMock(return_value=current),
) as get_torrents:
result = await requests_router.get_download_progress(
"3909", user={"username": "viewer", "role": "user"}
)
get_torrents.assert_awaited_once_with("abc123")
self.assertEqual(result["state"], "downloading")
self.assertEqual(result["torrents"][0]["progressPercent"], 13.4)
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
def test_set_user_email_is_case_insensitive(self) -> None:
created = db.create_user_if_missing(