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:
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user