Show actual collector search activity in request pipeline
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m4s

This commit is contained in:
2026-09-06 21:14:42 +12:00
parent dec1dd902c
commit 1851fa9753
6 changed files with 386 additions and 28 deletions
+2
View File
@@ -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"
+61
View File
@@ -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)
+50 -24
View File
@@ -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))