diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index ffbb50b..e169e06 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -420,6 +420,10 @@ def _build_presentation( missing = int(availability.get("missing") or 0) total = int(availability.get("total") or 0) partial = available > 0 and missing > 0 + jellyfin_partial = bool( + jellyfin_found and snapshot.request_type == RequestType.tv and missing > 0 + ) + fully_available = bool(jellyfin_found and not jellyfin_partial) download_visible = bool(download.get("visible")) download_state = str(download.get("state") or "not_started") @@ -432,7 +436,7 @@ def _build_presentation( f"The request was approved, but it has not reached the {collector} collector yet. " "Adding it to the library queue is the next step." ) - elif jellyfin_found and partial: + elif jellyfin_partial: status_label = f"Partially available — {available} of {total} episodes collected" meaning = ( f"Some of this request is ready to watch. {collector} is still looking for " @@ -497,7 +501,11 @@ def _build_presentation( meaning = snapshot.state_reason or "Magent is checking where this request is in the collection process." action_ids = [action.id for action in snapshot.actions] - if "resume_torrent" in action_ids: + if fully_available: + next_title = "Ready to watch" + next_description = "Collection is complete. Open the title on the media server when you are ready." + recommended = [] + elif "resume_torrent" in action_ids: next_title = "Resume the interrupted download" next_description = "The download exists but is not currently progressing. Resume it to continue collection." recommended = ["resume_torrent"] @@ -562,7 +570,9 @@ def _build_presentation( else: library_state, library_summary = "waiting", "Waiting for collector information" - if download_visible: + if fully_available: + search_state, search_summary = "complete", "No further search needed" + elif download_visible: search_state = "complete" search_summary = "A release was found" elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie): @@ -575,7 +585,15 @@ def _build_presentation( else: search_state, search_summary = "waiting", "Search has not started" - if download_visible: + completed_download_summary = ( + "The requested content has been collected and is available to watch. " + "No further action is needed." + ) + if fully_available: + download_stage_state, download_summary = "complete", completed_download_summary + pipeline_download_visible = False + pipeline_torrents: List[Dict[str, Any]] = [] + elif download_visible: download_stage_state = { "downloading": "active", "paused": "attention", @@ -584,19 +602,34 @@ def _build_presentation( "error": "attention", }.get(download_state, "waiting") download_summary = str(download.get("summary") or "A prior download attempt was found") + pipeline_download_visible = True + pipeline_torrents = download.get("torrents") or [] else: download_stage_state, download_summary = "waiting", "No download attempt yet" + pipeline_download_visible = False + pipeline_torrents = [] - if jellyfin_found and partial: + if jellyfin_partial: available_state, available_summary = "partial", f"{available} of {total} episodes available" elif jellyfin_found: available_state, available_summary = "complete", "Available to watch" else: available_state, available_summary = "waiting", "Not available on the media server yet" + display_download = dict(download) + if fully_available: + display_download.update( + { + "visible": False, + "state": "completed", + "summary": completed_download_summary, + "torrents": [], + } + ) + return { "status": {"label": status_label, "meaning": meaning}, - "download": download, + "download": display_download, "nextStep": { "title": next_title, "description": next_description, @@ -621,15 +654,19 @@ def _build_presentation( "label": "Release search", "state": search_state, "summary": search_summary, - "actionIds": [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids], + "actionIds": [] if fully_available else [ + action_id + for action_id in ("search_auto", "search_releases") + if action_id in action_ids + ], }, { "id": "download", - "label": "Download", + "label": "Download complete" if fully_available else "Download", "state": download_stage_state, "summary": download_summary, - "visible": download_visible, - "torrents": download.get("torrents") or [], + "visible": pipeline_download_visible, + "torrents": pipeline_torrents, }, { "id": "available", @@ -1158,7 +1195,13 @@ async def build_snapshot(request_id: str) -> Snapshot: query = quote(snapshot.title or "") jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}" availability = arr_details.get("availability") or {} - is_partial = bool(jellyfin_available and int(availability.get("missing") or 0) > 0) + is_partial = bool( + jellyfin_available + and snapshot.request_type == RequestType.tv + and int(availability.get("missing") or 0) > 0 + ) + if jellyfin_available and not is_partial: + snapshot.actions = [] snapshot.raw = { "jellyseerr": jelly_request, "arr": { diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index e79ecd6..79eddaa 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -228,6 +228,82 @@ class RequestPresentationTests(unittest.TestCase): download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download") self.assertEqual(download_stage["summary"], "No download attempt yet") + def test_available_content_replaces_stale_download_warning_with_completion(self) -> None: + snapshot = Snapshot( + request_id="3909", + title="Example", + request_type=RequestType.tv, + state=NormalizedState.completed, + actions=[], + ) + + presentation = _build_presentation( + snapshot, + approved=True, + arr_state="available", + arr_details={ + "availability": {"available": 6, "missing": 0, "total": 6, "seasons": []} + }, + prowlarr_state="ok", + download={ + "visible": True, + "state": "missing", + "summary": "A previous download was observed, but it is not currently visible in qBittorrent.", + "torrents": [], + }, + jellyfin_found=True, + jellyfin_link="https://media.test/title/3909", + ) + + self.assertFalse(presentation["download"]["visible"]) + self.assertEqual(presentation["download"]["state"], "completed") + self.assertEqual(presentation["nextStep"]["title"], "Ready to watch") + self.assertEqual(presentation["nextStep"]["actionIds"], []) + download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download") + self.assertEqual(download_stage["label"], "Download complete") + self.assertEqual(download_stage["state"], "complete") + self.assertFalse(download_stage["visible"]) + self.assertEqual( + download_stage["summary"], + "The requested content has been collected and is available to watch. No further action is needed.", + ) + search_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "search") + self.assertEqual(search_stage["state"], "complete") + self.assertEqual(search_stage["actionIds"], []) + + def test_partially_available_content_keeps_missing_download_attention(self) -> None: + snapshot = Snapshot( + request_id="3909", + title="Example", + request_type=RequestType.tv, + state=NormalizedState.importing, + actions=[], + ) + + presentation = _build_presentation( + snapshot, + approved=True, + arr_state="added", + arr_details={ + "availability": {"available": 3, "missing": 3, "total": 6, "seasons": []} + }, + prowlarr_state="ok", + download={ + "visible": True, + "state": "missing", + "summary": "A previous download is no longer visible.", + "torrents": [], + }, + jellyfin_found=True, + jellyfin_link="https://media.test/title/3909", + ) + + self.assertTrue(presentation["download"]["visible"]) + download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download") + self.assertEqual(download_stage["label"], "Download") + self.assertEqual(download_stage["state"], "attention") + self.assertTrue(download_stage["visible"]) + class LiveDownloadProgressTests(unittest.IsolatedAsyncioTestCase): async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None: