1300 lines
53 KiB
Python
1300 lines
53 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_request_cache_payload,
|
|
get_request_cache_by_id,
|
|
get_request_download_evidence,
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
|
|
_jellyfin_scan_key = "jellyfin_scan_last_at"
|
|
|
|
|
|
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)
|
|
|
|
provider_priority = ("tmdb", "tvdb", "imdb")
|
|
for key in provider_priority:
|
|
request_id = request_provider_ids.get(key)
|
|
item_id = item_provider_ids.get(key)
|
|
if request_id and item_id and request_id == item_id:
|
|
return True
|
|
|
|
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
|
|
|
|
if request_type == RequestType.tv:
|
|
for candidate in item_titles:
|
|
if candidate and (candidate.startswith(request_title) or request_title.startswith(candidate)):
|
|
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 _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")
|
|
|
|
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. {collector} is still looking for "
|
|
f"{missing} missing episode{'s' if missing != 1 else ''}."
|
|
)
|
|
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_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",
|
|
}
|
|
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"
|
|
)
|
|
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":
|
|
search_state, search_summary = "complete", "A release was collected"
|
|
elif download_visible:
|
|
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"
|
|
)
|
|
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_state, available_summary = "partial", f"{available} of {total} episodes available"
|
|
elif jellyfin_found:
|
|
available_state, available_summary = "complete", "Available to watch"
|
|
elif arr_state == "available":
|
|
available_state, available_summary = "active", "Waiting for the media server to index this title"
|
|
else:
|
|
available_state, available_summary = "waiting", "Not available on the media server 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,
|
|
"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 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",
|
|
"state": available_state,
|
|
"summary": available_summary,
|
|
"link": jellyfin_link,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
async def build_snapshot(request_id: str) -> Snapshot:
|
|
timeline = []
|
|
runtime = get_runtime_settings()
|
|
|
|
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
|
|
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["availability"] = _episode_availability(episodes)
|
|
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"
|
|
elif arr_item.get("isAvailable"):
|
|
arr_state = "searching"
|
|
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
|
|
except Exception as exc:
|
|
arr_state = "error"
|
|
arr_details["error"] = str(exc)
|
|
|
|
if arr_state is None:
|
|
arr_state = "unknown"
|
|
|
|
_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 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
|
|
|
|
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(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 []
|
|
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.",
|
|
)
|
|
)
|
|
|
|
download_ids = _download_ids(_queue_records(arr_queue))
|
|
if download_ids and qbittorrent.configured():
|
|
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 = {
|
|
"jellyseerr": jelly_request,
|
|
"arr": {
|
|
"item": arr_item,
|
|
"queue": arr_queue,
|
|
},
|
|
"jellyfin": {
|
|
"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,
|
|
)
|
|
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
|