Show actual collector search activity in request pipeline
This commit is contained in:
@@ -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:
|
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
|
||||||
return f"Checking releases through {service}…", f"{service} returned release information"
|
return f"Checking releases through {service}…", f"{service} returned release information"
|
||||||
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
|
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"
|
return f"Sending a command to {service}…", f"{service} accepted the command"
|
||||||
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
|
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
|
||||||
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
|
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -27,6 +27,7 @@ from ..db import (
|
|||||||
clear_seerr_media_failure,
|
clear_seerr_media_failure,
|
||||||
)
|
)
|
||||||
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||||
|
from .collector_search import read_search_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -666,6 +667,19 @@ def _build_presentation(
|
|||||||
fully_available = bool(jellyfin_found and not jellyfin_partial)
|
fully_available = bool(jellyfin_found and not jellyfin_partial)
|
||||||
download_visible = bool(download.get("visible"))
|
download_visible = bool(download.get("visible"))
|
||||||
download_state = str(download.get("state") or "not_started")
|
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:
|
if snapshot.state == NormalizedState.requested:
|
||||||
status_label = "Waiting for approval"
|
status_label = "Waiting for approval"
|
||||||
@@ -679,8 +693,8 @@ def _build_presentation(
|
|||||||
elif jellyfin_partial:
|
elif jellyfin_partial:
|
||||||
status_label = f"Partially available — {available} of {total} episodes collected"
|
status_label = f"Partially available — {available} of {total} episodes collected"
|
||||||
meaning = (
|
meaning = (
|
||||||
f"Some of this request is ready to watch. {collector} is still looking for "
|
f"Some of this request is ready to watch; {missing} episode{'s' if missing != 1 else ''} "
|
||||||
f"{missing} 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}:
|
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
|
||||||
status_label = "Available to watch"
|
status_label = "Available to watch"
|
||||||
@@ -760,6 +774,10 @@ def _build_presentation(
|
|||||||
next_title = "Add this request to the library queue"
|
next_title = "Add this request to the library queue"
|
||||||
next_description = f"Send the approved request to {collector} so collection can begin."
|
next_description = f"Send the approved request to {collector} so collection can begin."
|
||||||
recommended = ["readd_to_arr"]
|
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:
|
elif "search_auto" in action_ids or "search_releases" in action_ids:
|
||||||
if snapshot.request_type == RequestType.tv and missing:
|
if snapshot.request_type == RequestType.tv and missing:
|
||||||
target = f"the {missing} missing episode{'s' if missing != 1 else ''}"
|
target = f"the {missing} missing episode{'s' if missing != 1 else ''}"
|
||||||
@@ -806,38 +824,38 @@ def _build_presentation(
|
|||||||
"state": "complete" if approved else "active",
|
"state": "complete" if approved else "active",
|
||||||
"summary": "Approved for collection" if approved else "Waiting for approval",
|
"summary": "Approved for collection" if approved else "Waiting for approval",
|
||||||
}
|
}
|
||||||
|
library_state_label = None
|
||||||
if arr_state == "missing":
|
if arr_state == "missing":
|
||||||
library_state, library_summary = "attention", "Not yet added to the collector"
|
library_state, library_summary = "attention", "Not yet added to the collector"
|
||||||
elif arr_state == "error":
|
elif arr_state == "error":
|
||||||
library_state, library_summary = "attention", f"Unable to read {collector}"
|
library_state, library_summary = "attention", f"Unable to read {collector}"
|
||||||
elif partial:
|
elif fully_available or (arr_state == "available" and not partial):
|
||||||
library_state, library_summary = "partial", f"{available} of {total} episodes collected"
|
library_state, library_summary = "complete", "Collection complete — no search needed"
|
||||||
elif arr_state == "available":
|
elif arr_state in {"added", "searching", "available"}:
|
||||||
library_state, library_summary = "complete", "Collection complete"
|
library_state = "active" if search_in_progress else "attention" if search_status == "unavailable" else "waiting"
|
||||||
elif arr_state in {"added", "searching"}:
|
library_state_label = search_label
|
||||||
library_state = "active" if missing or not available else "complete"
|
library_summary = search_detail
|
||||||
library_summary = (
|
if download_visible and download_state == "downloading" and not search_in_progress:
|
||||||
f"{missing} episode{'s' if missing != 1 else ''} still missing"
|
library_state, library_state_label = "active", "Downloading"
|
||||||
if snapshot.request_type == RequestType.tv and missing
|
library_summary = f"Download in progress. {search_detail}"
|
||||||
else "In the library queue"
|
if partial:
|
||||||
)
|
library_state = "partial"
|
||||||
|
library_summary = f"{available} of {total} episodes collected. {library_summary}"
|
||||||
else:
|
else:
|
||||||
library_state, library_summary = "waiting", "Waiting for collector information"
|
library_state, library_summary = "waiting", "Waiting for collector information"
|
||||||
|
|
||||||
if fully_available:
|
if fully_available:
|
||||||
search_state, search_summary = "complete", "No further search needed"
|
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"
|
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_state = "complete"
|
||||||
search_summary = "A release was found"
|
search_summary = "A release was found"
|
||||||
elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie):
|
elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie):
|
||||||
search_state = "active" if prowlarr_state == "ok" else "attention"
|
search_state = "waiting" if search_status == "idle" and prowlarr_state == "ok" else "attention"
|
||||||
search_summary = (
|
search_summary = search_detail
|
||||||
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"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
search_state, search_summary = "waiting", "Search has not started"
|
search_state, search_summary = "waiting", "Search has not started"
|
||||||
|
|
||||||
@@ -917,6 +935,8 @@ def _build_presentation(
|
|||||||
"id": "library",
|
"id": "library",
|
||||||
"label": "Library collection",
|
"label": "Library collection",
|
||||||
"state": library_state,
|
"state": library_state,
|
||||||
|
"stateLabel": library_state_label or library_state,
|
||||||
|
"searchStatus": search_status,
|
||||||
"summary": library_summary,
|
"summary": library_summary,
|
||||||
"available": available,
|
"available": available,
|
||||||
"missing": missing,
|
"missing": missing,
|
||||||
@@ -929,7 +949,7 @@ def _build_presentation(
|
|||||||
"label": "Release search",
|
"label": "Release search",
|
||||||
"state": search_state,
|
"state": search_state,
|
||||||
"summary": search_summary,
|
"summary": search_summary,
|
||||||
"actionIds": [] if fully_available else [
|
"actionIds": [] if fully_available or search_in_progress else [
|
||||||
action_id
|
action_id
|
||||||
for action_id in ("search_auto", "search_releases")
|
for action_id in ("search_auto", "search_releases")
|
||||||
if action_id in action_ids
|
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_queue = _filter_queue(arr_queue, series_id, RequestType.tv)
|
||||||
arr_details["queue"] = arr_queue
|
arr_details["queue"] = arr_queue
|
||||||
episodes = await sonarr.get_episodes(series_id)
|
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)
|
arr_details["availability"] = _episode_availability(episodes)
|
||||||
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
||||||
if missing_by_season:
|
if missing_by_season:
|
||||||
@@ -1187,8 +1210,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
if arr_item:
|
if arr_item:
|
||||||
if arr_item.get("hasFile"):
|
if arr_item.get("hasFile"):
|
||||||
arr_state = "available"
|
arr_state = "available"
|
||||||
elif arr_item.get("isAvailable"):
|
|
||||||
arr_state = "searching"
|
|
||||||
else:
|
else:
|
||||||
arr_state = "added"
|
arr_state = "added"
|
||||||
else:
|
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 = await radarr.get_queue(int(arr_item["id"]))
|
||||||
arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
|
arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
|
||||||
arr_details["queue"] = arr_queue
|
arr_details["queue"] = arr_queue
|
||||||
|
arr_details["search"] = {
|
||||||
|
"state": await read_search_status(radarr, RequestType.movie, int(arr_item["id"]))
|
||||||
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
arr_state = "error"
|
arr_state = "error"
|
||||||
arr_details["error"] = str(exc)
|
arr_details["error"] = str(exc)
|
||||||
|
|
||||||
if arr_state is None:
|
if arr_state is None:
|
||||||
arr_state = "unknown"
|
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)
|
_apply_arr_identity(snapshot, arr_item)
|
||||||
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
|
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -26,6 +26,7 @@ type PipelineStage = {
|
|||||||
label: string
|
label: string
|
||||||
state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string
|
state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string
|
||||||
stateLabel?: string
|
stateLabel?: string
|
||||||
|
searchStatus?: 'searching' | 'queued' | 'idle' | 'unavailable'
|
||||||
summary: string
|
summary: string
|
||||||
available?: number
|
available?: number
|
||||||
missing?: number
|
missing?: number
|
||||||
@@ -320,6 +321,11 @@ export default function RequestTimelinePage() {
|
|||||||
snapshot?.presentation?.repairActivity?.visible &&
|
snapshot?.presentation?.repairActivity?.visible &&
|
||||||
!['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '')
|
!['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 = () => {
|
const closeReleasePicker = () => {
|
||||||
if (busyAction?.startsWith('grab:')) return
|
if (busyAction?.startsWith('grab:')) return
|
||||||
@@ -404,8 +410,10 @@ export default function RequestTimelinePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken() || !requestId) return
|
if (!getToken() || !requestId) return
|
||||||
let stopped = false
|
let stopped = false
|
||||||
|
let refreshing = false
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (document.visibilityState === 'hidden') return
|
if (document.visibilityState === 'hidden' || refreshing) return
|
||||||
|
refreshing = true
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`)
|
const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`)
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -418,17 +426,19 @@ export default function RequestTimelinePage() {
|
|||||||
if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload)
|
if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!stopped) console.error(error)
|
if (!stopped) console.error(error)
|
||||||
|
} finally {
|
||||||
|
refreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const timer = window.setInterval(
|
const timer = window.setInterval(
|
||||||
() => void refresh(),
|
() => void refresh(),
|
||||||
awaitingMediaIndex || repairIsActive ? 5_000 : 15_000,
|
awaitingMediaIndex || repairIsActive || searchIsActive ? 5_000 : 15_000,
|
||||||
)
|
)
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true
|
stopped = true
|
||||||
window.clearInterval(timer)
|
window.clearInterval(timer)
|
||||||
}
|
}
|
||||||
}, [awaitingMediaIndex, repairIsActive, requestId, router])
|
}, [awaitingMediaIndex, repairIsActive, searchIsActive, requestId, router])
|
||||||
|
|
||||||
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')
|
||||||
@@ -864,7 +874,7 @@ export default function RequestTimelinePage() {
|
|||||||
<span className={`request-stage-state state-${stage.state}`}>{stage.stateLabel ?? stage.state}</span>
|
<span className={`request-stage-state state-${stage.state}`}>{stage.stateLabel ?? stage.state}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3>{stage.label}</h3>
|
<h3>{stage.label}</h3>
|
||||||
<p>{stage.summary}</p>
|
<p aria-live={stage.id === 'library' ? 'polite' : undefined} aria-atomic={stage.id === 'library' ? true : undefined}>{stage.summary}</p>
|
||||||
|
|
||||||
{stage.id === 'library' && Boolean(stage.total) && (
|
{stage.id === 'library' && Boolean(stage.total) && (
|
||||||
<div className="request-availability-meter">
|
<div className="request-availability-meter">
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Isolated UI checks: all API responses are fixtures. No searches or downloads run.
|
||||||
|
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'
|
||||||
|
const output = process.env.REVIEW_DIR
|
||||||
|
|
||||||
|
const states = {
|
||||||
|
queued: ['active', 'Search queued', 'Search queued — waiting for Radarr to start.'],
|
||||||
|
searching: ['active', 'Searching', 'Radarr is searching for a matching release.'],
|
||||||
|
idle: ['waiting', 'Not searching', 'Not currently searching for this movie.'],
|
||||||
|
unavailable: ['attention', 'Search unknown', 'Search status unavailable — unable to check Radarr.'],
|
||||||
|
complete: ['complete', 'complete', 'Collection complete — no search needed'],
|
||||||
|
}
|
||||||
|
const fixture = (mode, partial = false) => {
|
||||||
|
const [state, stateLabel, summary] = states[mode]
|
||||||
|
const library = {
|
||||||
|
id: 'library', label: 'Library collection', state: partial ? 'partial' : state, stateLabel,
|
||||||
|
searchStatus: mode === 'complete' ? 'idle' : mode,
|
||||||
|
summary: partial ? '22 of 24 episodes collected. ' + summary : summary,
|
||||||
|
total: partial ? 24 : 1, available: partial ? 22 : mode === 'complete' ? 1 : 0, missing: partial ? 2 : mode === 'complete' ? 0 : 1,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
request_id: '12', title: 'Search status review', request_type: partial ? 'tv' : 'movie',
|
||||||
|
state: mode === 'complete' ? 'COMPLETED' : 'ADDED_TO_ARR', timeline: [], actions: [],
|
||||||
|
presentation: {
|
||||||
|
status: { label: 'In the library queue', meaning: 'Tracking collection.' },
|
||||||
|
download: { visible: false },
|
||||||
|
nextStep: { title: 'Tracking your request', description: 'The status updates automatically.', actionIds: [] },
|
||||||
|
pipeline: [
|
||||||
|
{ id: 'requested', label: 'Requested', state: 'complete', summary: 'Request received' },
|
||||||
|
{ id: 'approved', label: 'Approved', state: 'complete', summary: 'Approved for collection' },
|
||||||
|
library,
|
||||||
|
{ id: 'search', label: 'Release search', state, summary },
|
||||||
|
{ id: 'download', label: 'Download', state: 'waiting', summary: 'No download attempt yet' },
|
||||||
|
{ id: 'available', label: 'Media server', state: 'waiting', summary: 'Not yet available' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
;(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 mode = 'queued'
|
||||||
|
let partial = false
|
||||||
|
let polls = 0
|
||||||
|
const mutations = []
|
||||||
|
const errors = []
|
||||||
|
await context.route('**/api/**', (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const path = new URL(request.url()).pathname
|
||||||
|
if (request.method() !== 'GET') mutations.push(path)
|
||||||
|
const reply = (json) => route.fulfill({ json })
|
||||||
|
if (path === '/api/auth/me') return reply({ username: 'Review member', role: 'user' })
|
||||||
|
if (path.endsWith('/snapshot')) { polls++; return reply(fixture(mode, partial)) }
|
||||||
|
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
|
||||||
|
if (path.includes('/branding/')) return route.fulfill({ status: 404 })
|
||||||
|
return reply({ navigation: { showRequests: true }, requests: [], services: [] })
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
|
const card = page.locator('.request-stage').filter({ has: page.getByRole('heading', { name: 'Library collection', exact: true }) })
|
||||||
|
const verify = async () => {
|
||||||
|
await card.getByText(states[mode][1], { exact: true }).waitFor()
|
||||||
|
assert.equal(await card.getByRole('progressbar').getAttribute('aria-valuenow'), partial ? '22' : mode === 'complete' ? '1' : '0')
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0)
|
||||||
|
await card.evaluate((element) => element.scrollIntoView({ block: 'center', behavior: 'instant' }))
|
||||||
|
if (output) await card.screenshot({ path: output + `/search-${page.viewportSize().width}-${partial ? 'partial-' : ''}${mode}.png` })
|
||||||
|
}
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 })
|
||||||
|
for (mode of Object.keys(states)) {
|
||||||
|
await page.goto(base + '/requests/12')
|
||||||
|
await verify()
|
||||||
|
}
|
||||||
|
mode = 'idle'; partial = true
|
||||||
|
await page.goto(base + '/requests/12')
|
||||||
|
await verify()
|
||||||
|
partial = false
|
||||||
|
}
|
||||||
|
// Confirm queued -> searching -> idle updates without a click or page reload.
|
||||||
|
mode = 'queued'
|
||||||
|
await page.goto(base + '/requests/12')
|
||||||
|
await verify()
|
||||||
|
const initialPolls = polls
|
||||||
|
mode = 'searching'
|
||||||
|
await card.getByText('Searching', { exact: true }).waitFor({ timeout: 8000 })
|
||||||
|
await verify()
|
||||||
|
mode = 'idle'
|
||||||
|
await card.getByText('Not searching', { exact: true }).waitFor({ timeout: 8000 })
|
||||||
|
await verify()
|
||||||
|
assert.ok(polls >= initialPolls + 2, 'Active search must refresh automatically')
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
assert.deepEqual(mutations, [])
|
||||||
|
console.log('PASS: desktop/mobile search cards, partial collection, and queued → searching → idle automatic updates; no API mutations.')
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
})().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||||
Reference in New Issue
Block a user