diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index beb0eea..da99095 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -241,6 +241,8 @@ def _operation_messages(service: str, method: str, path: str) -> tuple[str, str] if service in {"Radarr", "Sonarr"} and "/release" in normalized_path: return f"Checking releases through {service}…", f"{service} returned release information" if service in {"Radarr", "Sonarr"} and "/command" in normalized_path: + if normalized_method == "GET": + return f"Checking {service}'s search activity…", f"{service} returned its current activity" return f"Sending a command to {service}…", f"{service} accepted the command" if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH": return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search" diff --git a/backend/app/services/collector_search.py b/backend/app/services/collector_search.py new file mode 100644 index 0000000..87fbe79 --- /dev/null +++ b/backend/app/services/collector_search.py @@ -0,0 +1,61 @@ +"""Read title-specific search activity without starting a search or changing monitoring.""" + +from typing import Any + +from ..clients.base import ApiClient +from ..models import RequestType + + +def _ids(values: Any) -> set[int]: + if not isinstance(values, list): + return set() + return {value for value in values if type(value) is int and value > 0} + + +def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str: + """Only a matching queued/started search is evidence of current activity. + + Completed commands, RSS syncs and library-wide jobs do not establish that this + title is being searched. Episode searches are matched using Sonarr episode IDs. + """ + if not isinstance(commands, list): + return "unavailable" + episode_ids = _ids([ + episode.get("id") for episode in (episodes if isinstance(episodes, list) else []) + if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id + ]) + queued = False + for command in commands: + if not isinstance(command, dict): + continue + body = command.get("body") + if not isinstance(body, dict): + continue + name = str(command.get("name") or body.get("name") or "").lower() + if request_type == RequestType.movie: + matches = name == "moviessearch" and item_id in _ids(body.get("movieIds")) + else: + matches = ( + name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id + ) or ( + name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds"))) + ) + if not matches or command.get("ended"): + continue + status = str(command.get("status", "")).lower() + if status in {"started", "1"}: + return "searching" + if status in {"queued", "0"}: + queued = True + return "queued" if queued else "idle" + + +async def read_search_status( + client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None, +) -> str: + try: + commands = await client.get("/api/v3/command", timeout_seconds=3.0) + except Exception: + # Search telemetry must not turn a healthy library record into an error. + return "unavailable" + return search_status(commands, request_type, item_id, episodes) diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index 6ca02de..ccf5a86 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -27,6 +27,7 @@ from ..db import ( clear_seerr_media_failure, ) from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop +from .collector_search import read_search_status logger = logging.getLogger(__name__) @@ -666,6 +667,19 @@ def _build_presentation( fully_available = bool(jellyfin_found and not jellyfin_partial) download_visible = bool(download.get("visible")) download_state = str(download.get("state") or "not_started") + search_status = str((arr_details.get("search") or {}).get("state") or "unavailable") + search_in_progress = search_status in {"searching", "queued"} + search_label = { + "searching": "Searching", + "queued": "Search queued", + "idle": "Not searching", + }.get(search_status, "Search unknown") + search_target = "episode releases" if snapshot.request_type == RequestType.tv else "a matching release" + search_detail = { + "searching": f"{collector} is searching for {search_target}.", + "queued": f"Search queued — waiting for {collector} to start.", + "idle": f"Not currently searching for this {'series' if snapshot.request_type == RequestType.tv else 'movie'}.", + }.get(search_status, f"Search status unavailable — unable to check {collector}.") if snapshot.state == NormalizedState.requested: status_label = "Waiting for approval" @@ -679,8 +693,8 @@ def _build_presentation( 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 " - f"{missing} missing episode{'s' if missing != 1 else ''}." + f"Some of this request is ready to watch; {missing} episode{'s' if missing != 1 else ''} " + f"{'is' if missing == 1 else 'are'} still missing. {search_detail}" ) elif snapshot.state in {NormalizedState.completed, NormalizedState.available}: status_label = "Available to watch" @@ -760,6 +774,10 @@ def _build_presentation( next_title = "Add this request to the library queue" next_description = f"Send the approved request to {collector} so collection can begin." recommended = ["readd_to_arr"] + elif search_in_progress and arr_state != "available": + next_title = "Wait for the search results" if search_status == "searching" else "Wait for the queued search" + next_description = f"{search_detail} This page will update automatically." + recommended = [] elif "search_auto" in action_ids or "search_releases" in action_ids: if snapshot.request_type == RequestType.tv and missing: target = f"the {missing} missing episode{'s' if missing != 1 else ''}" @@ -806,38 +824,38 @@ def _build_presentation( "state": "complete" if approved else "active", "summary": "Approved for collection" if approved else "Waiting for approval", } + library_state_label = None if arr_state == "missing": library_state, library_summary = "attention", "Not yet added to the collector" elif arr_state == "error": library_state, library_summary = "attention", f"Unable to read {collector}" - elif partial: - library_state, library_summary = "partial", f"{available} of {total} episodes collected" - elif arr_state == "available": - library_state, library_summary = "complete", "Collection complete" - elif arr_state in {"added", "searching"}: - library_state = "active" if missing or not available else "complete" - library_summary = ( - f"{missing} episode{'s' if missing != 1 else ''} still missing" - if snapshot.request_type == RequestType.tv and missing - else "In the library queue" - ) + elif fully_available or (arr_state == "available" and not partial): + library_state, library_summary = "complete", "Collection complete — no search needed" + elif arr_state in {"added", "searching", "available"}: + library_state = "active" if search_in_progress else "attention" if search_status == "unavailable" else "waiting" + library_state_label = search_label + library_summary = search_detail + if download_visible and download_state == "downloading" and not search_in_progress: + library_state, library_state_label = "active", "Downloading" + library_summary = f"Download in progress. {search_detail}" + if partial: + library_state = "partial" + library_summary = f"{available} of {total} episodes collected. {library_summary}" else: library_state, library_summary = "waiting", "Waiting for collector information" if fully_available: search_state, search_summary = "complete", "No further search needed" - elif arr_state == "available": + elif arr_state == "available" and not partial: search_state, search_summary = "complete", "A release was collected" - elif download_visible: + elif search_in_progress: + search_state, search_summary = "active", search_detail + elif download_visible and download_state in {"downloading", "paused", "completed"} and not partial: search_state = "complete" search_summary = "A release was found" elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie): - search_state = "active" if prowlarr_state == "ok" else "attention" - search_summary = ( - f"Ready to search for {missing} missing episode{'s' if missing != 1 else ''}" - if snapshot.request_type == RequestType.tv and missing - else "Ready to search for a release" - ) + search_state = "waiting" if search_status == "idle" and prowlarr_state == "ok" else "attention" + search_summary = search_detail else: search_state, search_summary = "waiting", "Search has not started" @@ -917,6 +935,8 @@ def _build_presentation( "id": "library", "label": "Library collection", "state": library_state, + "stateLabel": library_state_label or library_state, + "searchStatus": search_status, "summary": library_summary, "available": available, "missing": missing, @@ -929,7 +949,7 @@ def _build_presentation( "label": "Release search", "state": search_state, "summary": search_summary, - "actionIds": [] if fully_available else [ + "actionIds": [] if fully_available or search_in_progress else [ action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids @@ -1144,6 +1164,9 @@ async def build_snapshot(request_id: str) -> Snapshot: arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv) arr_details["queue"] = arr_queue episodes = await sonarr.get_episodes(series_id) + arr_details["search"] = { + "state": await read_search_status(sonarr, RequestType.tv, series_id, episodes) + } arr_details["availability"] = _episode_availability(episodes) missing_by_season = _missing_episode_numbers_by_season(episodes) if missing_by_season: @@ -1187,8 +1210,6 @@ async def build_snapshot(request_id: str) -> Snapshot: if arr_item: if arr_item.get("hasFile"): arr_state = "available" - elif arr_item.get("isAvailable"): - arr_state = "searching" else: arr_state = "added" else: @@ -1203,12 +1224,17 @@ async def build_snapshot(request_id: str) -> Snapshot: arr_queue = await radarr.get_queue(int(arr_item["id"])) arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie) arr_details["queue"] = arr_queue + arr_details["search"] = { + "state": await read_search_status(radarr, RequestType.movie, int(arr_item["id"])) + } except Exception as exc: arr_state = "error" arr_details["error"] = str(exc) if arr_state is None: arr_state = "unknown" + if arr_state == "added" and (arr_details.get("search") or {}).get("state") == "searching": + arr_state = "searching" _apply_arr_identity(snapshot, arr_item) timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details)) diff --git a/backend/tests/test_collector_search.py b/backend/tests/test_collector_search.py new file mode 100644 index 0000000..11406bf --- /dev/null +++ b/backend/tests/test_collector_search.py @@ -0,0 +1,158 @@ +from contextlib import ExitStack +from types import SimpleNamespace +import unittest +from unittest.mock import AsyncMock, patch + +from backend.app.config import settings +from backend.app.models import NormalizedState, RequestType, Snapshot +from backend.app.services import snapshot as snapshot_service +from backend.app.services.collector_search import read_search_status, search_status + + +def command(name="MoviesSearch", status="started", **body): + return {"name": name, "status": status, "body": body} + + +class CollectorSearchTests(unittest.IsolatedAsyncioTestCase): + def test_movie_search_is_scoped_to_the_movie(self): + self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching") + self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle") + + def test_queued_search_and_running_search_priority(self): + queued = command(status="queued", movieIds=[12]) + self.assertEqual(search_status([queued], RequestType.movie, 12), "queued") + self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching") + + def test_terminal_commands_are_not_searching(self): + for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]: + with self.subTest(state=state): + self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle") + ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"} + self.assertEqual(search_status([ended], RequestType.movie, 12), "idle") + + def test_numeric_statuses(self): + for state, expected in [(0, "queued"), (1, "searching")]: + self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected) + + def test_series_and_season_searches(self): + for name in ["SeriesSearch", "SeasonSearch"]: + with self.subTest(name=name): + self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching") + self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle") + + def test_episode_search_uses_episode_ids_not_numbers(self): + episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}] + for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]: + self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected) + self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle") + + def test_background_tasks_and_unscoped_searches_are_not_title_searches(self): + for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]: + with self.subTest(name=name): + self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle") + + def test_empty_commands_are_idle_but_missing_response_is_unknown(self): + self.assertEqual(search_status([], RequestType.movie, 12), "idle") + for payload in [None, {}, {"error": "unavailable"}]: + self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable") + + async def test_check_is_read_only_with_a_short_timeout(self): + client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])])) + self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching") + client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0) + + async def test_service_failure_is_unknown_not_idle(self): + client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError())) + self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable") + + +class LibrarySearchPresentationTests(unittest.TestCase): + def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1, + arr_state="added", download_state="not_started", jellyfin=False): + snapshot = Snapshot(request_id="12", title="Example", request_type=media_type, + state=NormalizedState.added_to_arr) + return snapshot_service._build_presentation( + snapshot, approved=True, arr_state=arr_state, + arr_details={"search": {"state": search}, "availability": { + "available": available, "missing": missing, "total": available + missing, + }}, prowlarr_state="ok", + download={"visible": download_state != "not_started", "state": download_state, "torrents": []}, + jellyfin_found=jellyfin, jellyfin_link=None, + ) + + def stage(self, presentation, stage_id="library"): + return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id) + + def test_card_uses_actual_search_state(self): + for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"), + ("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]: + with self.subTest(state=state): + presentation = self.presentation(state) + library = self.stage(presentation) + self.assertEqual(library["stateLabel"], badge) + self.assertEqual(library["state"], style) + self.assertEqual(library["searchStatus"], state) + self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"]) + self.assertEqual(library["available"], 0) + self.assertEqual(library["missing"], 1) + + def test_partial_tv_retains_counts_and_search_activity(self): + for state in ["idle", "searching", "queued", "unavailable"]: + with self.subTest(state=state): + presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True) + library = self.stage(presentation) + self.assertEqual(library["state"], "partial") + self.assertEqual(library["searchStatus"], state) + self.assertIn("22 of 24 episodes collected", library["summary"]) + self.assertNotIn("is still looking", presentation["status"]["meaning"]) + + def test_collected_titles_dont_look_stuck_searching(self): + for jellyfin in [True, False]: + library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin)) + self.assertEqual(library["state"], "complete") + self.assertIn("no search needed", library["summary"]) + + def test_download_has_its_own_state_without_claiming_searching(self): + library = self.stage(self.presentation("idle", download_state="downloading")) + self.assertEqual(library["stateLabel"], "Downloading") + self.assertIn("Not currently searching", library["summary"]) + + def test_an_old_missing_download_does_not_mark_search_complete(self): + search = self.stage(self.presentation("idle", download_state="missing"), "search") + self.assertEqual(search["state"], "waiting") + + +class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase): + async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self): + for media_type in [RequestType.movie, RequestType.tv]: + for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]: + with self.subTest(media_type=media_type, search=expected), ExitStack() as stack: + runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"}) + item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True} + collector = SimpleNamespace( + get_movie_by_tmdb_id=AsyncMock(return_value=[item]), + get_series_by_tvdb_id=AsyncMock(return_value=[item]), + get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]), + get_queue=AsyncMock(return_value={"records": []}), + get=AsyncMock(return_value=commands), + ) + mocks = { + "get_runtime_settings": runtime, + "get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2, + "media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}}, + "get_request_cache_by_id": None, + "JellyseerrClient": SimpleNamespace(configured=lambda: False), + "JellyfinClient": SimpleNamespace(configured=lambda: False), + "QBittorrentClient": SimpleNamespace(configured=lambda: False), + "SonarrClient": collector, "RadarrClient": collector, + "ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])), + "get_request_download_evidence": {}, "_latest_repair_action": None, "save_snapshot": None, + } + for name, value in mocks.items(): + stack.enter_context(patch.object(snapshot_service, name, return_value=value)) + stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock())) + snapshot = await snapshot_service.build_snapshot("12") + collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0) + self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr) + library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library") + self.assertEqual(library["searchStatus"], expected) diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 21c7437..e064093 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -26,6 +26,7 @@ type PipelineStage = { label: string state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string stateLabel?: string + searchStatus?: 'searching' | 'queued' | 'idle' | 'unavailable' summary: string available?: number missing?: number @@ -320,6 +321,11 @@ export default function RequestTimelinePage() { snapshot?.presentation?.repairActivity?.visible && !['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '') ) + const searchIsActive = Boolean( + snapshot?.presentation?.pipeline?.some( + (stage) => stage.id === 'library' && ['searching', 'queued'].includes(stage.searchStatus ?? '') + ) + ) const closeReleasePicker = () => { if (busyAction?.startsWith('grab:')) return @@ -404,8 +410,10 @@ export default function RequestTimelinePage() { useEffect(() => { if (!getToken() || !requestId) return let stopped = false + let refreshing = false const refresh = async () => { - if (document.visibilityState === 'hidden') return + if (document.visibilityState === 'hidden' || refreshing) return + refreshing = true try { const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`) if (response.status === 401) { @@ -418,17 +426,19 @@ export default function RequestTimelinePage() { if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload) } catch (error) { if (!stopped) console.error(error) + } finally { + refreshing = false } } const timer = window.setInterval( () => void refresh(), - awaitingMediaIndex || repairIsActive ? 5_000 : 15_000, + awaitingMediaIndex || repairIsActive || searchIsActive ? 5_000 : 15_000, ) return () => { stopped = true window.clearInterval(timer) } - }, [awaitingMediaIndex, repairIsActive, requestId, router]) + }, [awaitingMediaIndex, repairIsActive, searchIsActive, requestId, router]) const liveDownloadKey = useMemo(() => { const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download') @@ -864,7 +874,7 @@ export default function RequestTimelinePage() { {stage.stateLabel ?? stage.state}
{stage.summary}
+{stage.summary}
{stage.id === 'library' && Boolean(stage.total) && (