Files
Magent/backend/app/services/snapshot.py
T
Assclaw 765b0d2033
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped
Match Jellyfin punctuation variants using provider metadata and strict fallback
2026-09-13 17:36:31 +12:00

1678 lines
72 KiB
Python

from typing import Any, Dict, List, Optional
import asyncio
import logging
import re
from datetime import datetime, timezone
from urllib.parse import quote
import httpx
from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellyfin import JellyfinClient
from ..clients.sonarr import SonarrClient
from ..clients.radarr import RadarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..runtime import get_runtime_settings
from ..db import (
save_snapshot,
get_recent_actions,
get_request_cache_payload,
get_request_cache_by_id,
get_request_download_evidence,
get_request_repairs,
complete_request_repair,
get_recent_snapshots,
get_setting,
set_setting,
is_seerr_media_failure_suppressed,
record_seerr_media_failure,
clear_seerr_media_failure,
)
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
from .collector_search import read_search_status
from .media_repair import current_cycle_torrents, evaluate_media_repair
from .download_labels import label_episode_downloads
logger = logging.getLogger(__name__)
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
_jellyfin_scan_key = "jellyfin_scan_last_at"
REPAIR_ACTIVITY_MAX_AGE = 7 * 24 * 60 * 60
REPAIR_ACTION_IDS = {"replace_media", "search_missing", "repair_subtitles"}
STATUS_LABELS = {
1: "Waiting for approval",
2: "Approved",
3: "Declined",
4: "Ready to watch",
5: "Working on it",
6: "Partially ready",
}
def _status_label(value: Any) -> str:
try:
numeric = int(value)
return STATUS_LABELS.get(numeric, f"Status {numeric}")
except (TypeError, ValueError):
return "Unknown"
def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
if isinstance(value, list):
return value[0] if value else None
if isinstance(value, dict):
return value
return None
def _apply_arr_identity(snapshot: Snapshot, arr_item: Any) -> None:
"""Use the collector's authoritative identity when cached Seerr metadata is sparse."""
if not isinstance(arr_item, dict):
return
if snapshot.title in {None, "", "Unknown"}:
title = arr_item.get("title") or arr_item.get("seriesTitle")
if isinstance(title, str) and title.strip():
snapshot.title = title.strip()
if not snapshot.year:
year = arr_item.get("year")
try:
snapshot.year = int(year) if year else snapshot.year
except (TypeError, ValueError):
pass
def _normalize_media_title(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
return normalized or None
def _canonical_provider_key(value: str) -> str:
normalized = value.strip().lower()
if normalized.endswith("id"):
normalized = normalized[:-2]
return normalized
def extract_request_provider_ids(payload: Any) -> Dict[str, str]:
provider_ids: Dict[str, str] = {}
candidates: List[Any] = []
if isinstance(payload, dict):
candidates.append(payload)
media = payload.get("media")
if isinstance(media, dict):
candidates.append(media)
for candidate in candidates:
if not isinstance(candidate, dict):
continue
embedded = candidate.get("ProviderIds") or candidate.get("providerIds")
if isinstance(embedded, dict):
for key, value in embedded.items():
if value is None:
continue
text = str(value).strip()
if text:
provider_ids[_canonical_provider_key(str(key))] = text
for key in ("tmdbId", "tvdbId", "imdbId", "tmdb_id", "tvdb_id", "imdb_id"):
value = candidate.get(key)
if value is None:
continue
text = str(value).strip()
if text:
provider_ids[_canonical_provider_key(key)] = text
return provider_ids
def jellyfin_item_matches_request(
item: Dict[str, Any],
*,
title: Optional[str],
year: Optional[int],
request_type: RequestType,
request_payload: Optional[Dict[str, Any]] = None,
) -> bool:
request_provider_ids = extract_request_provider_ids(request_payload or {})
item_provider_ids = extract_request_provider_ids(item)
shared = set(request_provider_ids) & set(item_provider_ids)
if shared:
# Conflicting metadata must never fall through to title matching.
return all(request_provider_ids[key] == item_provider_ids[key] for key in shared)
request_title = _normalize_media_title(title)
if not request_title:
return False
item_titles = [
_normalize_media_title(item.get("Name")),
_normalize_media_title(item.get("OriginalTitle")),
_normalize_media_title(item.get("SortName")),
_normalize_media_title(item.get("SeriesName")),
_normalize_media_title(item.get("title")),
]
item_titles = [candidate for candidate in item_titles if candidate]
item_year = item.get("ProductionYear") or item.get("Year")
try:
item_year_value = int(item_year) if item_year is not None else None
except (TypeError, ValueError):
item_year_value = None
if year and item_year_value and int(year) != item_year_value:
return False
if request_title in item_titles:
return True
return False
def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
response = exc.response
if response is None:
return None
try:
payload = response.json()
except ValueError:
payload = response.text
if isinstance(payload, dict):
message = payload.get("message") or payload.get("error")
return str(message).strip() if message else str(payload)
if isinstance(payload, str):
trimmed = payload.strip()
return trimmed or None
return str(payload)
def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
response = exc.response
if response is None:
return False
return response.status_code == 404 or response.status_code >= 500
async def _get_seerr_media_details(
jellyseerr: JellyseerrClient, request_type: RequestType, tmdb_id: int
) -> Optional[Dict[str, Any]]:
media_type = request_type.value
if media_type not in {"movie", "tv"}:
return None
if is_seerr_media_failure_suppressed(media_type, tmdb_id):
logger.debug("Seerr snapshot hydration suppressed: media_type=%s tmdb_id=%s", media_type, tmdb_id)
return None
try:
if request_type == RequestType.movie:
details = await jellyseerr.get_movie(int(tmdb_id))
else:
details = await jellyseerr.get_tv(int(tmdb_id))
except httpx.HTTPStatusError as exc:
if _should_persist_seerr_media_failure(exc):
record_seerr_media_failure(
media_type,
int(tmdb_id),
status_code=exc.response.status_code if exc.response is not None else None,
error_message=_extract_http_error_message(exc),
)
return None
if isinstance(details, dict):
clear_seerr_media_failure(media_type, int(tmdb_id))
return details
return None
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
collector_item = snapshot.raw.get("arr", {}).get("item") if isinstance(snapshot.raw, dict) else None
collector_stats = collector_item.get("statistics") if isinstance(collector_item, dict) else None
collector_has_file = bool(
isinstance(collector_item, dict)
and (
collector_item.get("hasFile")
or snapshot.request_type == RequestType.tv
and isinstance(collector_stats, dict)
and collector_stats.get("episodeFileCount")
)
)
if snapshot.state not in {NormalizedState.available, NormalizedState.completed} and not (
snapshot.state == NormalizedState.importing and collector_has_file
):
return
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return
last_scan = get_setting(_jellyfin_scan_key)
if last_scan:
try:
parsed = datetime.fromisoformat(last_scan.replace("Z", "+00:00"))
if (datetime.now(timezone.utc) - parsed).total_seconds() < JELLYFIN_SCAN_COOLDOWN_SECONDS:
return
except ValueError:
pass
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
if previous:
previous_payload = previous[0].get("payload") or {}
previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {}
if previous_jellyfin.get("found"):
return
try:
await client.refresh_library()
except Exception as exc:
logger.warning("Jellyfin library refresh failed: %s", exc)
return
set_setting(_jellyfin_scan_key, datetime.now(timezone.utc).isoformat())
logger.info("Jellyfin library refresh triggered: request_id=%s", snapshot.request_id)
def _queue_records(queue: Any) -> List[Dict[str, Any]]:
if isinstance(queue, dict):
records = queue.get("records")
if isinstance(records, list):
return records
if isinstance(queue, list):
return queue
return []
def _filter_queue(queue: Any, item_id: Optional[int], request_type: RequestType) -> Any:
if not item_id:
return queue
records = _queue_records(queue)
if not records:
return queue
key = "seriesId" if request_type == RequestType.tv else "movieId"
filtered = [record for record in records if record.get(key) == item_id]
if isinstance(queue, dict):
filtered_queue = dict(queue)
filtered_queue["records"] = filtered
filtered_queue["totalRecords"] = len(filtered)
return filtered_queue
return filtered
def _download_ids(records: List[Dict[str, Any]]) -> List[str]:
ids = []
for record in records:
download_id = record.get("downloadId") or record.get("download_id")
if isinstance(download_id, str) and download_id:
ids.append(download_id)
return ids
def _missing_episode_numbers_by_season(episodes: Any) -> Dict[int, List[int]]:
if not isinstance(episodes, list):
return {}
grouped: Dict[int, List[int]] = {}
now = datetime.now(timezone.utc)
for episode in episodes:
if not isinstance(episode, dict):
continue
if not episode.get("monitored", True):
continue
if episode.get("hasFile"):
continue
air_date = episode.get("airDateUtc")
if isinstance(air_date, str):
try:
aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00"))
except ValueError:
aired_at = None
if aired_at and aired_at > now:
continue
season_number = episode.get("seasonNumber")
episode_number = episode.get("episodeNumber")
if not isinstance(episode_number, int):
episode_number = episode.get("absoluteEpisodeNumber")
if isinstance(season_number, int) and isinstance(episode_number, int):
grouped.setdefault(season_number, []).append(episode_number)
for season_number in list(grouped.keys()):
grouped[season_number] = sorted(set(grouped[season_number]))
return grouped
def _episode_availability(episodes: Any) -> Dict[str, Any]:
if not isinstance(episodes, list):
return {"available": 0, "missing": 0, "total": 0, "seasons": []}
now = datetime.now(timezone.utc)
season_rows: Dict[int, Dict[str, Any]] = {}
for episode in episodes:
if not isinstance(episode, dict) or not episode.get("monitored", True):
continue
air_date = episode.get("airDateUtc")
if isinstance(air_date, str):
try:
aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00"))
except ValueError:
aired_at = None
if aired_at and aired_at > now:
continue
season_number = episode.get("seasonNumber")
if not isinstance(season_number, int):
continue
row = season_rows.setdefault(
season_number,
{"seasonNumber": season_number, "available": 0, "missing": 0, "total": 0},
)
row["total"] += 1
if episode.get("hasFile"):
row["available"] += 1
else:
row["missing"] += 1
seasons = [season_rows[key] for key in sorted(season_rows)]
return {
"available": sum(int(row["available"]) for row in seasons),
"missing": sum(int(row["missing"]) for row in seasons),
"total": sum(int(row["total"]) for row in seasons),
"seasons": seasons,
}
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
if not torrents:
return {"state": "idle", "message": "0 active downloads."}
downloading_states = {"downloading", "stalleddl", "queueddl", "checkingdl", "forceddl"}
paused_states = {"pauseddl", "pausedup"}
completed_states = {"uploading", "stalledup", "queuedup", "checkingup", "forcedup", "stoppedup"}
downloading = [t for t in torrents if str(t.get("state", "")).lower() in downloading_states]
paused = [t for t in torrents if str(t.get("state", "")).lower() in paused_states]
completed = [t for t in torrents if str(t.get("state", "")).lower() in completed_states]
if downloading:
return {
"state": "downloading",
"message": f"Downloading ({len(downloading)} active).",
}
if paused:
return {
"state": "paused",
"message": f"Paused ({len(paused)} paused).",
}
if completed:
return {
"state": "completed",
"message": f"Completed/seeding ({len(completed)} seeding).",
}
return {
"state": "idle",
"message": "0 active downloads.",
}
def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]:
if not path:
return None
if not path.startswith("/"):
path = f"/{path}"
if cache_mode == "cache":
return f"/images/tmdb?path={quote(path)}&size={size}"
return f"https://image.tmdb.org/t/p/{size}{path}"
def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]:
progress = torrent.get("progress")
try:
numeric = float(progress)
except (TypeError, ValueError):
numeric = -1
if 0 <= numeric <= 1:
return round(numeric * 100, 1)
try:
size = float(torrent.get("size"))
amount_left = float(torrent.get("amount_left"))
except (TypeError, ValueError):
return None
if size <= 0:
return None
return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
def _parse_action_time(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _latest_repair_action(request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
for action in get_recent_actions(request_id, 25):
if action.get("action_id") not in REPAIR_ACTION_IDS:
continue
created_at = _parse_action_time(action.get("created_at"))
if created_at is None:
continue
age_seconds = (current_time - created_at).total_seconds()
if 0 <= age_seconds <= REPAIR_ACTIVITY_MAX_AGE:
return action
return None
def _build_repair_activity(
snapshot: Snapshot,
*,
action: Optional[Dict[str, Any]],
arr_state: str,
arr_details: Dict[str, Any],
download: Dict[str, Any],
jellyfin_found: bool,
) -> Optional[Dict[str, Any]]:
if not action:
return None
action_id = str(action.get("action_id") or "")
collector = (
"Bazarr"
if action_id == "repair_subtitles"
else ("Sonarr" if snapshot.request_type == RequestType.tv else "Radarr")
)
action_ok = str(action.get("status") or "").lower() == "ok"
action_message = str(action.get("message") or "The repair action was recorded.")
download_state = str(download.get("state") or "not_started")
download_visible = bool(download.get("visible"))
availability = arr_details.get("availability")
if not isinstance(availability, dict):
availability = {}
missing = int(availability.get("missing") or 0)
total = int(availability.get("total") or 0)
collection_complete = arr_state == "available" and (
snapshot.request_type == RequestType.movie or (total > 0 and missing == 0)
)
submitted_step = {
"id": "submitted",
"label": "Repair requested",
"state": "complete",
"detail": "Magent recorded the issue and started the selected repair.",
}
if not action_ok:
return {
"visible": True,
"actionId": action_id,
"state": "attention",
"headline": "Repair needs attention",
"message": action_message,
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": f"{collector} hand-off",
"state": "attention",
"detail": action_message,
},
],
}
if action_id == "repair_subtitles":
return {
"visible": True,
"actionId": action_id,
"state": "searching",
"headline": "Subtitle repair is running",
"message": (
f"{action_message} Bazarr is checking the configured subtitle providers; "
"the issue can be confirmed once the replacement track is available."
),
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": "Bazarr accepted the search",
"state": "complete",
"detail": action_message,
},
{
"id": "result",
"label": "Subtitle result",
"state": "active",
"detail": "Waiting for Bazarr to find and apply a suitable subtitle track.",
},
],
}
if collection_complete:
headline = "Repair collected"
message = (
f"{collector} now reports the replacement file as collected. "
+ (
"It is also available in Grizzlyflix."
if jellyfin_found
else "Grizzlyflix is indexing the updated file now."
)
)
state = "complete" if jellyfin_found else "indexing"
download_step_state = "complete"
download_step_detail = f"{collector} reports the replacement file as collected and imported."
available_step_state = "complete" if jellyfin_found else "active"
elif download_visible and download_state in {"downloading", "paused", "completed", "error", "missing"}:
state = {
"downloading": "downloading",
"completed": "importing",
"paused": "attention",
"error": "attention",
"missing": "attention",
}[download_state]
headline = {
"downloading": "Replacement download in progress",
"completed": "Replacement downloaded — waiting for import",
"paused": "Replacement download paused",
"error": "Replacement download cannot be checked",
"missing": "Replacement hand-off needs checking",
}[download_state]
message = {
"downloading": "The replacement is downloading now.",
"paused": "The replacement download is paused and needs attention.",
"completed": f"The download has finished and is waiting for {collector} to import it.",
"error": "Magent cannot currently read the replacement download from qBittorrent.",
"missing": "The collector reported a download, but it is not currently visible in qBittorrent.",
}[download_state]
download_step_state = "active" if download_state == "downloading" else (
"complete" if download_state == "completed" else "attention"
)
download_step_detail = str(
download.get("summary") or "Magent found the replacement download in qBittorrent."
)
available_step_state = "waiting"
else:
state = "searching"
headline = "Replacement search in progress"
message = (
f"{action_message} {collector} has accepted the search, but no replacement download "
"has been selected yet. Magent will keep checking."
)
download_step_state = "waiting"
download_step_detail = "Waiting for a suitable release to be selected."
available_step_state = "waiting"
return {
"visible": True,
"actionId": action_id,
"state": state,
"headline": headline,
"message": message,
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": f"{collector} accepted the search",
"state": "complete",
"detail": action_message,
},
{
"id": "download",
"label": "Replacement download",
"state": download_step_state,
"detail": download_step_detail,
},
{
"id": "available",
"label": "Updated media available",
"state": available_step_state,
"detail": (
"The repaired title is available in Grizzlyflix."
if jellyfin_found and collection_complete
else (
"The media server is indexing the replacement."
if collection_complete
else "Waiting for download and import to finish."
)
),
},
],
}
def _build_presentation(
snapshot: Snapshot,
*,
approved: bool,
arr_state: str,
arr_details: Dict[str, Any],
prowlarr_state: str,
download: Dict[str, Any],
jellyfin_found: bool,
jellyfin_link: Optional[str],
) -> Dict[str, Any]:
collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
noun = "episode" if snapshot.request_type == RequestType.tv else "movie"
availability = arr_details.get("availability")
if not isinstance(availability, dict):
availability = {"available": 0, "missing": 0, "total": 0, "seasons": []}
available = int(availability.get("available") or 0)
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")
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"
meaning = "This request has been received, but it must be approved before collection can begin."
elif snapshot.state == NormalizedState.needs_add:
status_label = "Approved, but not yet in the library queue"
meaning = (
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_partial:
status_label = f"Partially available — {available} of {total} episodes collected"
meaning = (
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"
meaning = "Collection is complete and the title is available on the media server."
elif download_visible and download_state == "paused":
status_label = "Download paused"
meaning = "A release was collected, but its qBittorrent download is paused and needs to be resumed."
elif download_visible and download_state == "missing":
status_label = "Download attempt is no longer visible"
meaning = (
"A download was previously queued for this request, but qBittorrent no longer reports it. "
"A fresh release search may be required."
)
elif download_visible and download_state == "error":
status_label = "Unable to read the current download"
meaning = (
"A download attempt exists, but Magent cannot currently read its progress from qBittorrent."
)
elif snapshot.state == NormalizedState.downloading:
status_label = "Download in progress"
meaning = "A release has been collected and is currently downloading."
elif snapshot.state == NormalizedState.importing:
if arr_state == "available" and not jellyfin_found:
status_label = "Collected — waiting for the media server"
meaning = (
f"{collector} has collected and imported this title, but it is not visible on "
"the media server yet."
)
else:
status_label = "Downloaded — waiting for library import"
meaning = f"The download has finished and {collector} is preparing it for the media server."
elif arr_state == "error":
status_label = "Unable to read the library queue"
meaning = (
f"The request is approved, but Magent could not read its current state from {collector}. "
"The service may be temporarily unavailable."
)
elif arr_state in {"added", "searching"} and snapshot.request_type == RequestType.tv and total:
if partial:
status_label = f"Partially collected — {missing} episode{'s' if missing != 1 else ''} still missing"
meaning = (
f"The request was approved and sent to {collector}. {available} of {total} aired "
f"episodes have been collected; {missing} still need a matching release."
)
elif missing:
status_label = f"Added to library queue — waiting for {missing} episode{'s' if missing != 1 else ''}"
meaning = (
f"The request was approved and sent to the {collector} collector, but none of the "
f"{total} aired episodes have been collected yet."
)
else:
status_label = "Added to library queue"
meaning = f"The request was approved and sent to the {collector} collector."
elif arr_state in {"added", "searching"}:
status_label = "Added to library queue — waiting for a matching release"
meaning = (
f"The request was approved and sent to the {collector} collector, but a usable release "
"has not been collected yet."
)
elif snapshot.state == NormalizedState.failed:
status_label = "This request needs attention"
meaning = snapshot.state_reason or "Magent could not determine the next stage for this request."
else:
status_label = "Approved — preparing collection" if approved else "Request received"
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 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"]
elif "readd_to_arr" in action_ids:
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 ''}"
else:
target = f"a matching {noun} release"
next_title = f"Search for {target}"
next_description = (
"Run an automatic search, or review the available releases and choose one manually."
)
recommended = [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids]
elif download_state == "downloading":
next_title = "Let the current download finish"
next_description = "Magent is tracking the active download; no action is needed right now."
recommended = []
elif snapshot.state == NormalizedState.importing and arr_state == "available":
next_title = "Wait for the media server to index this title"
next_description = (
f"{collector} has completed its work. Use Recheck request to see whether the title "
"has appeared on the media server."
)
recommended = []
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
next_title = "Ready to watch"
next_description = "Collection is complete. Open the title on the media server when you are ready."
recommended = []
elif snapshot.state == NormalizedState.requested:
next_title = "Wait for approval"
next_description = "An administrator must approve this request before collection can start."
recommended = []
else:
next_title = "Magent is checking the next step"
next_description = "No safe action is available until the current service state is known."
recommended = []
requested_stage = {
"id": "requested",
"label": "Requested",
"state": "complete",
"summary": "Request received",
}
approved_stage = {
"id": "approved",
"label": "Approved",
"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 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" and not partial:
search_state, search_summary = "complete", "A release was collected"
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 = "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"
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 arr_state == "available":
download_stage_state = "complete"
download_summary = f"{collector} has imported the collected file"
pipeline_download_visible = False
pipeline_torrents = []
elif download_visible:
download_stage_state = {
"downloading": "active",
"paused": "attention",
"completed": "complete",
"missing": "attention",
"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_partial:
available_label = "Partially available"
available_state = "partial"
available_state_label = "Partly ready"
available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix."
elif jellyfin_found:
available_label = "Available to watch"
available_state = "complete"
available_state_label = "Ready"
available_summary = "This title is ready to watch in Grizzlyflix."
elif arr_state == "available":
available_label = "Adding to Grizzlyflix"
available_state = "active"
available_state_label = "Indexing"
available_summary = "The download is complete. Grizzlyflix is indexing this title now."
else:
available_label = "Media server"
available_state = "waiting"
available_state_label = "Waiting"
available_summary = "This title has not reached Grizzlyflix 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": display_download,
"nextStep": {
"title": next_title,
"description": next_description,
"actionIds": recommended,
},
"pipeline": [
requested_stage,
approved_stage,
{
"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,
"total": total,
"seasons": availability.get("seasons") or [],
"missingEpisodes": arr_details.get("missingEpisodes") or {},
},
{
"id": "search",
"label": "Release search",
"state": search_state,
"summary": search_summary,
"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
],
},
{
"id": "download",
"label": "Download complete" if fully_available else "Download",
"state": download_stage_state,
"summary": download_summary,
"visible": pipeline_download_visible,
"torrents": pipeline_torrents,
},
{
"id": "available",
"label": available_label,
"state": available_state,
"stateLabel": available_state_label,
"summary": available_summary,
"link": jellyfin_link,
},
],
}
def _apply_repair_presentation(
snapshot: Snapshot, repairs: List[Dict[str, Any]], arr_details: Dict[str, Any],
arr_state: str, download: Dict[str, Any], catalog_found: bool,
jellyfin_item: Any, public_url: Optional[str],
) -> None:
"""Describe the replacement, without erasing approval or unaffected episodes."""
imported = all(repair.get("phase") == "indexing" for repair in repairs)
unavailable = arr_state == "error" or any(repair.get("phase") == "unavailable" for repair in repairs)
latest = repairs[-1]
activity = _build_repair_activity(
snapshot,
action={"action_id": latest.get("actionId"), "status": "ok",
"created_at": latest.get("startedAt"), "message": "A new collection cycle was requested."},
arr_state="available" if imported else arr_state,
arr_details={"availability": {"total": 1, "missing": 0}} if imported else arr_details,
download=download, jellyfin_found=False,
) or {}
search = (arr_details.get("search") or {}).get("state")
pipeline = {stage["id"]: stage for stage in snapshot.presentation["pipeline"]}
if imported:
label = "Replacement collected — updating Grizzlyflix"
meaning = "The replacement has been imported. Waiting for Grizzlyflix to index the updated file."
snapshot.state = NormalizedState.importing
pipeline["download"].update(state="complete", summary="The replacement has been imported.", torrents=[], visible=False)
pipeline["available"].update(label="Updating Grizzlyflix", state="active", stateLabel="Indexing", summary=meaning)
snapshot.presentation["nextStep"] = {
"title": "Wait for the updated file", "description": "This page will update when Grizzlyflix confirms the replacement.", "actionIds": [],
}
elif unavailable:
label = "Repair status temporarily unavailable"
meaning = "Magent cannot verify the replacement right now. The old library entry is not confirmation that the repair is complete."
snapshot.state = NormalizedState.unknown
pipeline["available"].update(state="waiting", stateLabel="Unconfirmed", summary="Waiting for the replacement to be verified.")
snapshot.presentation["nextStep"] = {"title": "Recheck the request", "description": "Magent will retry automatically. You can also use Recheck request.", "actionIds": []}
activity.update(state="attention", headline=label, message=meaning)
elif download.get("visible"):
label = activity.get("headline", "Replacement in progress")
meaning = activity.get("message", "Magent is tracking the replacement download.")
snapshot.state = NormalizedState.importing if download.get("state") == "completed" else NormalizedState.downloading
else:
label = "Searching for a replacement" if search == "searching" else "Replacement search queued" if search == "queued" else "Waiting for a replacement"
meaning = "The affected content is being replaced. " + {
"searching": "The collector is looking for a suitable release.",
"queued": "The collector has queued the search.",
"idle": "No download has started and the collector is not currently searching.",
}.get(search, "Magent cannot currently confirm the search status.")
snapshot.state = NormalizedState.searching if search in {"searching", "queued"} else NormalizedState.added_to_arr
pipeline["download"].update(label="Replacement download", state="waiting", stateLabel="Pending",
summary="Waiting for a replacement download to start.", torrents=[], visible=False)
activity.update(state="searching" if search in {"searching", "queued"} else "waiting", headline=label, message=meaning)
previous_activity = snapshot.presentation.get("repairActivity") or {}
if search not in {"searching", "queued"} and previous_activity.get("state") == "attention" and (previous_activity.get("updatedAt") or "") >= latest["startedAt"]:
label, meaning = "Repair needs attention", str(previous_activity.get("message") or meaning)
activity.update(state="attention", headline=label, message=meaning)
snapshot.presentation["status"] = {"label": label, "meaning": meaning}
snapshot.presentation["repairActivity"] = activity
if not imported:
pipeline["available"].update(summary="The affected content will be available after the replacement is imported and indexed.")
counts = arr_details.get("availability") or {}
targets = {episode.get("id") for repair in repairs for episode in repair.get("episodes", [])}
# For a series, keep a route to unaffected episodes without claiming that the
# repaired ones are ready (even while a stale series entry remains indexed).
has_unaffected = snapshot.request_type == RequestType.tv and int(counts.get("available") or 0) > (len(targets) if imported else 0)
if has_unaffected and catalog_found and isinstance(jellyfin_item, dict) and jellyfin_item.get("Id"):
link = f"{public_url.rstrip('/')}/web/index.html#!/details?id={quote(str(jellyfin_item['Id']))}" if public_url else None
pipeline["available"].update(label="Partially available", state="partial", stateLabel="Repair in progress",
summary="Other collected episodes remain available. The selected episodes are being replaced." if not imported else "Other episodes remain available. Waiting for Grizzlyflix to index the repaired episodes.", link=link)
snapshot.raw["jellyfin"].update(partial=True, link=link)
async def build_snapshot(request_id: str) -> Snapshot:
timeline = []
runtime = get_runtime_settings()
repair_records = await asyncio.to_thread(get_request_repairs, request_id, active_only=False)
repair_cycle = repair_records[-1]["startedAt"] if repair_records else None
active_repairs = [record for record in repair_records if not record.get("completedAt")]
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
snapshot = Snapshot(
request_id=request_id,
title="Unknown",
state=NormalizedState.unknown,
state_reason="Awaiting configuration",
)
cached_request = None
mode = (runtime.requests_data_source or "prefer_cache").lower()
if mode != "always_js" and request_id.isdigit():
cached_request = get_request_cache_payload(int(request_id))
if cached_request is not None:
logging.getLogger(__name__).debug(
"snapshot cache hit: request_id=%s mode=%s", request_id, mode
)
else:
logging.getLogger(__name__).debug(
"snapshot cache miss: request_id=%s mode=%s", request_id, mode
)
if cached_request is not None:
cache_meta = get_request_cache_by_id(int(request_id))
cached_title = cache_meta.get("title") if cache_meta else None
if cached_title and isinstance(cached_request, dict):
media = cached_request.get("media")
if not isinstance(media, dict):
media = {}
cached_request["media"] = media
if not media.get("title") and not media.get("name"):
media["title"] = cached_title
media["name"] = cached_title
if not cached_request.get("title") and not cached_request.get("name"):
cached_request["title"] = cached_title
allow_remote = mode == "always_js" and jellyseerr.configured()
if not jellyseerr.configured() and not cached_request:
timeline.append(TimelineHop(service="Seerr", status="not_configured"))
timeline.append(TimelineHop(service="Sonarr/Radarr", status="not_configured"))
timeline.append(TimelineHop(service="Prowlarr", status="not_configured"))
timeline.append(TimelineHop(service="qBittorrent", status="not_configured"))
snapshot.timeline = timeline
return snapshot
if cached_request is None and not allow_remote:
timeline.append(TimelineHop(service="Seerr", status="cache_miss"))
snapshot.timeline = timeline
snapshot.state = NormalizedState.unknown
snapshot.state_reason = "Request not found in cache"
return snapshot
jelly_request = cached_request
if allow_remote and (jelly_request is None or mode == "always_js"):
try:
jelly_request = await jellyseerr.get_request(request_id)
logging.getLogger(__name__).debug(
"snapshot Seerr fetch: request_id=%s mode=%s", request_id, mode
)
except Exception as exc:
timeline.append(TimelineHop(service="Seerr", status="error", details={"error": str(exc)}))
snapshot.timeline = timeline
snapshot.state = NormalizedState.failed
snapshot.state_reason = "Failed to reach Seerr"
return snapshot
if not jelly_request:
timeline.append(TimelineHop(service="Seerr", status="not_found"))
snapshot.timeline = timeline
snapshot.state = NormalizedState.unknown
snapshot.state_reason = "Request not found in Seerr"
return snapshot
jelly_status = jelly_request.get("status", "unknown")
jelly_status_label = _status_label(jelly_status)
jelly_type = jelly_request.get("type") or "unknown"
media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {}
if not isinstance(media, dict):
media = {}
snapshot.title = (
media.get("title")
or media.get("name")
or jelly_request.get("title")
or jelly_request.get("name")
or "Unknown"
)
snapshot.year = media.get("year") or jelly_request.get("year")
snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown
poster_path = None
backdrop_path = None
if isinstance(media, dict):
poster_path = media.get("posterPath") or media.get("poster_path")
backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured():
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
if isinstance(details, dict):
if snapshot.request_type == RequestType.movie:
snapshot.title = details.get("title") or snapshot.title
release_date = details.get("releaseDate")
snapshot.year = int(release_date[:4]) if release_date else snapshot.year
elif snapshot.request_type == RequestType.tv:
snapshot.title = details.get("name") or details.get("title") or snapshot.title
first_air = details.get("firstAirDate")
snapshot.year = int(first_air[:4]) if first_air else snapshot.year
poster_path = poster_path or details.get("posterPath") or details.get("poster_path")
backdrop_path = (
backdrop_path
or details.get("backdropPath")
or details.get("backdrop_path")
)
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
snapshot.artwork = {
"poster_path": poster_path,
"backdrop_path": backdrop_path,
"poster_url": _artwork_url(poster_path, "w342", cache_mode),
"backdrop_url": _artwork_url(backdrop_path, "w780", cache_mode),
}
timeline.append(
TimelineHop(
service="Seerr",
status=jelly_status_label,
details={
"requestedBy": jelly_request.get("requestedBy", {}).get("displayName")
or jelly_request.get("requestedBy", {}).get("username")
or jelly_request.get("requestedBy", {}).get("jellyfinUsername")
or jelly_request.get("requestedBy", {}).get("email"),
"createdAt": jelly_request.get("createdAt"),
"updatedAt": jelly_request.get("updatedAt"),
"approved": jelly_request.get("isApproved"),
"statusCode": jelly_status,
},
)
)
arr_state = None
arr_details: Dict[str, Any] = {}
arr_item = None
arr_queue = None
episodes = None
media_status = jelly_request.get("media", {}).get("status")
try:
media_status_code = int(media_status) if media_status is not None else None
except (TypeError, ValueError):
media_status_code = None
if snapshot.request_type == RequestType.tv:
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
if tvdb_id:
try:
series = await sonarr.get_series_by_tvdb_id(int(tvdb_id))
arr_item = _pick_first(series)
arr_details["series"] = arr_item
arr_state = "added" if arr_item else "missing"
if arr_item:
stats = arr_item.get("statistics") if isinstance(arr_item, dict) else None
if isinstance(stats, dict):
file_count = stats.get("episodeFileCount")
total_count = (
stats.get("totalEpisodeCount")
if isinstance(stats.get("totalEpisodeCount"), int)
else stats.get("episodeCount")
)
if (
isinstance(file_count, int)
and isinstance(total_count, int)
and total_count > 0
and file_count >= total_count
):
arr_state = "available"
if arr_item and isinstance(arr_item.get("id"), int):
series_id = int(arr_item["id"])
arr_queue = await sonarr.get_queue(series_id)
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)
counts = arr_details["availability"]
arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added"
missing_by_season = _missing_episode_numbers_by_season(episodes)
if missing_by_season:
arr_details["missingEpisodes"] = missing_by_season
except Exception as exc:
arr_state = "error"
arr_details["error"] = str(exc)
elif snapshot.request_type == RequestType.movie:
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
try:
movie = await radarr.get_movie_by_tmdb_id(int(tmdb_id))
arr_item = _pick_first(movie)
if not arr_item:
title_hint = (
jelly_request.get("media", {}).get("title")
or jelly_request.get("title")
or snapshot.title
)
year_hint = (
jelly_request.get("media", {}).get("year")
or jelly_request.get("year")
or snapshot.year
)
try:
all_movies = await radarr.get_movies()
except Exception:
all_movies = None
if isinstance(all_movies, list):
for candidate in all_movies:
if not isinstance(candidate, dict):
continue
if tmdb_id and candidate.get("tmdbId") == int(tmdb_id):
arr_item = candidate
break
if title_hint and candidate.get("title") == title_hint:
if not year_hint or candidate.get("year") == year_hint:
arr_item = candidate
break
arr_details["movie"] = arr_item
if arr_item:
if arr_item.get("hasFile"):
arr_state = "available"
else:
arr_state = "added"
else:
arr_state = "missing"
arr_details["availability"] = {
"available": 1 if arr_item and arr_item.get("hasFile") else 0,
"missing": 0 if arr_item and arr_item.get("hasFile") else 1,
"total": 1,
"seasons": [],
}
if arr_item and isinstance(arr_item.get("id"), int):
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))
prowlarr_state = "unknown"
try:
prowlarr_health = await prowlarr.get_health()
if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0:
prowlarr_state = "issues"
timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health}))
else:
prowlarr_state = "ok"
timeline.append(TimelineHop(service="Prowlarr", status="ok"))
except Exception as exc:
prowlarr_state = "error"
timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)}))
jellyfin_available = False
jellyfin_item = None
if jellyfin.configured() and snapshot.title:
types = ["Movie"] if snapshot.request_type == RequestType.movie else ["Series"]
try:
search = await jellyfin.search_items(snapshot.title, types, limit=50)
except Exception:
search = None
if isinstance(search, dict):
items = search.get("Items") or search.get("items") or []
for item in items:
if not isinstance(item, dict):
continue
if jellyfin_item_matches_request(
item,
title=snapshot.title,
year=snapshot.year,
request_type=snapshot.request_type,
request_payload=jelly_request,
):
jellyfin_available = True
jellyfin_item = item
break
if jellyfin_available and not active_repairs and arr_state == "missing" and runtime.jellyfin_sync_to_arr:
arr_details["note"] = "Found in Jellyfin but not tracked in Sonarr/Radarr."
if snapshot.request_type == RequestType.movie:
if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if radarr_client.configured():
root_folder = await _resolve_root_folder_path(
radarr_client, runtime.radarr_root_folder, "Radarr"
)
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
try:
await radarr_client.add_movie(
int(tmdb_id),
runtime.radarr_quality_profile_id,
root_folder,
monitored=False,
search_for_movie=False,
)
except Exception:
pass
if snapshot.request_type == RequestType.tv:
if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder:
sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if sonarr_client.configured():
root_folder = await _resolve_root_folder_path(
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
)
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
if tvdb_id:
try:
await sonarr_client.add_series(
int(tvdb_id),
runtime.sonarr_quality_profile_id,
root_folder,
monitored=False,
search_missing=False,
)
except Exception:
pass
catalog_found = jellyfin_available
pending_repairs = []
for repair in active_repairs:
try:
evidence = await evaluate_media_repair(
repair, arr_item, {"found": catalog_found, "item": jellyfin_item}, episodes=episodes,
)
except Exception:
logger.warning("Unable to verify replacement request_id=%s", request_id)
evidence = {"complete": False, "phase": "unavailable"}
if evidence.get("complete"):
await asyncio.to_thread(complete_request_repair, repair["id"])
else:
pending_repairs.append({**repair, "phase": evidence.get("phase")})
repair_imported = bool(pending_repairs) and all(r["phase"] == "indexing" for r in pending_repairs)
if pending_repairs:
# Jellyfin can retain the original item while its replacement is missing.
jellyfin_available = False
if arr_state == "available" and not repair_imported:
arr_state = "added"
elif snapshot.request_type == RequestType.movie and arr_state == "added":
# Also reconcile externally removed files, not only Magent repairs.
jellyfin_available = False
qbit_state = "not_started"
qbit_message = "No download attempt has been observed."
download_ids = _download_ids(_queue_records(arr_queue))
download_history = await asyncio.to_thread(get_request_download_evidence, request_id, 100)
torrent_list: List[Dict[str, Any]] = []
download_visible = bool(download_ids) or bool(download_history.get("observed"))
qbit_error = None
try:
if qbittorrent.configured():
if download_ids:
torrents = await qbittorrent.get_torrents_by_hashes("|".join(h.lower() for h in download_ids))
torrent_list = torrents if isinstance(torrents, list) else []
else:
request_tag = f"magent-{request_id}"
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
torrent_list = torrents if isinstance(torrents, list) else []
label_episode_downloads(torrent_list, arr_queue)
unfiltered_torrents = torrent_list
torrent_list = current_cycle_torrents(torrent_list, repair_cycle)
discarded_hashes = {str(t.get("hash") or "").lower() for t in unfiltered_torrents if t not in torrent_list}
if repair_cycle and not download_history.get("observed"):
current_hashes = {str(t.get("hash") or "").lower() for t in torrent_list}
discarded_hashes.update(
str(h).lower() for repair in active_repairs for h in repair.get("previousDownloadIds", [])
if str(h).lower() not in current_hashes
)
download_ids = [h for h in download_ids if h.lower() not in discarded_hashes]
download_visible = bool(download_ids) or bool(download_history.get("observed"))
for torrent in torrent_list:
if isinstance(torrent, dict):
torrent["progressPercent"] = _torrent_progress(torrent)
if torrent_list:
download_visible = True
summary = _summarize_qbit(torrent_list)
qbit_state = str(summary.get("state") or "idle")
qbit_message = str(summary.get("message") or "Download found in qBittorrent.")
elif download_ids:
qbit_state = "missing"
qbit_message = (
"The collector queued a download, but it is no longer visible in qBittorrent."
)
elif download_history.get("observed"):
qbit_state = "missing"
qbit_message = (
"A previous download was observed, but it is not currently visible in qBittorrent."
)
except Exception as exc:
qbit_error = str(exc)
if download_visible:
qbit_state = "error"
qbit_message = (
"A download attempt exists, but Magent cannot currently read its state from qBittorrent."
)
download_presentation = {
"visible": download_visible,
"observed": download_visible,
"state": qbit_state,
"summary": qbit_message,
"torrents": torrent_list,
"lastSeenAt": download_history.get("last_seen_at"),
}
timeline.append(
TimelineHop(
service="qBittorrent",
status=qbit_state,
details={
**download_presentation,
"error": qbit_error,
},
)
)
status_code = None
try:
status_code = int(jelly_status)
except (TypeError, ValueError):
status_code = None
derived_approved = bool(jelly_request.get("isApproved")) or status_code in {2, 4, 5, 6}
if derived_approved:
snapshot.state = NormalizedState.approved
snapshot.state_reason = "Approved and queued for processing."
else:
snapshot.state = NormalizedState.requested
snapshot.state_reason = "Waiting for approval before we can search."
queue_records = _queue_records(arr_queue)
if qbit_state in {"downloading", "paused"}:
snapshot.state = NormalizedState.downloading
snapshot.state_reason = "Downloading in qBittorrent."
if qbit_message:
snapshot.state_reason = qbit_message
elif qbit_state == "completed":
if arr_state == "available":
snapshot.state = NormalizedState.importing
snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it."
else:
snapshot.state = NormalizedState.importing
snapshot.state_reason = "Download finished. Waiting for library import."
elif queue_records:
if arr_state == "missing":
snapshot.state_reason = "Queue shows a download, but qBittorrent has no active torrent."
else:
snapshot.state_reason = "Waiting for download to start in qBittorrent."
elif arr_state == "missing" and derived_approved:
snapshot.state = NormalizedState.needs_add
snapshot.state_reason = "Approved, but not yet added to Sonarr/Radarr."
elif arr_state == "searching":
snapshot.state = NormalizedState.searching
snapshot.state_reason = "Searching for a matching release."
elif arr_state == "available":
snapshot.state = NormalizedState.importing
snapshot.state_reason = "Collected by Sonarr/Radarr and waiting for the media server to index it."
elif arr_state == "added" and snapshot.state == NormalizedState.approved:
snapshot.state = NormalizedState.added_to_arr
snapshot.state_reason = "Item is present in Sonarr/Radarr"
if jellyfin_available:
missing_episodes = arr_details.get("missingEpisodes")
if snapshot.request_type == RequestType.tv and isinstance(missing_episodes, dict) and missing_episodes:
snapshot.state = NormalizedState.importing
snapshot.state_reason = "Some episodes are available in Jellyfin, but the request is still incomplete."
for hop in timeline:
if hop.service == "Seerr":
hop.status = "Partially ready"
else:
snapshot.state = NormalizedState.completed
snapshot.state_reason = "Ready to watch in Jellyfin."
for hop in timeline:
if hop.service == "Seerr":
hop.status = "Available"
elif hop.service == "Sonarr/Radarr" and hop.status not in {"error"}:
hop.status = "available"
snapshot.timeline = timeline
actions: List[ActionOption] = []
if arr_state == "missing":
actions.append(
ActionOption(
id="readd_to_arr",
label=f"Add to {'Sonarr' if snapshot.request_type == RequestType.tv else 'Radarr'}",
risk="medium",
description="Send this approved request to the library collector.",
)
)
elif arr_item and arr_state != "available" and qbit_state not in {"downloading", "completed"}:
missing_count = int((arr_details.get("availability") or {}).get("missing") or 0)
automatic_label = (
f"Search automatically for {missing_count} missing episode{'s' if missing_count != 1 else ''}"
if snapshot.request_type == RequestType.tv and missing_count
else "Search automatically for a release"
)
actions.append(
ActionOption(
id="search_auto",
label=automatic_label,
risk="low",
description="Ask the library collector to find and download the best permitted match.",
)
)
actions.append(
ActionOption(
id="search_releases",
label="Review available releases",
risk="low",
description="Search the configured indexers and choose a release yourself.",
)
)
if download_ids and qbittorrent.configured() and qbit_state == "paused":
actions.append(
ActionOption(
id="resume_torrent",
label="Resume the download",
risk="low",
description="Resume the existing qBittorrent job if it is paused or stalled.",
)
)
snapshot.actions = actions
jellyfin_link = None
if runtime.jellyfin_public_url and jellyfin_available:
base_url = runtime.jellyfin_public_url.rstrip("/")
jellyfin_item_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
if jellyfin_item_id:
jellyfin_link = f"{base_url}/web/index.html#!/details?id={quote(str(jellyfin_item_id))}"
else:
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 snapshot.request_type == RequestType.tv
and int(availability.get("missing") or 0) > 0
)
if jellyfin_available and not is_partial:
snapshot.actions = []
snapshot.raw = {
"repairCycle": repair_cycle,
"jellyseerr": jelly_request,
"arr": {
"item": arr_item,
"queue": arr_queue,
"episodes": episodes,
},
"jellyfin": {
"catalogFound": catalog_found,
"publicUrl": runtime.jellyfin_public_url,
"found": jellyfin_available,
"available": jellyfin_available and snapshot.state in {
NormalizedState.available,
NormalizedState.completed,
},
"partial": is_partial,
"link": jellyfin_link,
"item": jellyfin_item,
},
"qbittorrent": {
**download_presentation,
"downloadIds": download_ids,
"error": qbit_error,
},
}
snapshot.presentation = _build_presentation(
snapshot,
approved=derived_approved,
arr_state=arr_state,
arr_details=arr_details,
prowlarr_state=prowlarr_state,
download=download_presentation,
jellyfin_found=jellyfin_available,
jellyfin_link=jellyfin_link,
)
repair_action = await asyncio.to_thread(_latest_repair_action, request_id)
repair_activity = _build_repair_activity(
snapshot,
action=repair_action,
arr_state=arr_state,
arr_details=arr_details,
download=download_presentation,
jellyfin_found=jellyfin_available,
)
if repair_activity:
snapshot.presentation["repairActivity"] = repair_activity
snapshot.presentation["repairCycle"] = repair_cycle
if pending_repairs:
_apply_repair_presentation(
snapshot, pending_repairs, arr_details, arr_state, download_presentation,
catalog_found, jellyfin_item, runtime.jellyfin_public_url,
)
status_presentation = snapshot.presentation.get("status")
if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
snapshot.state_reason = str(status_presentation["meaning"])
await _maybe_refresh_jellyfin(snapshot)
await asyncio.to_thread(save_snapshot, snapshot)
return snapshot