Track repair collection cycles and reconcile request availability
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m49s

This commit is contained in:
2026-09-06 22:06:30 +12:00
parent 8d720de500
commit 009bb35032
11 changed files with 762 additions and 151 deletions
+71 -2
View File
@@ -187,6 +187,16 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
def init_db() -> None:
with _connect() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS request_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
started_at TEXT NOT NULL,
tracking_json TEXT NOT NULL,
completed_at TEXT,
UNIQUE(request_id, started_at)
)
""")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS snapshots (
@@ -706,6 +716,59 @@ def init_db() -> None:
pass
_backfill_auth_providers()
ensure_admin_user()
_backfill_request_repairs()
def start_request_repair(tracking: Dict[str, Any]) -> None:
"""Persist the new collection cycle before a managed file is removed."""
with _connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO request_repairs (request_id, started_at, tracking_json) VALUES (?, ?, ?)",
(str(tracking["requestId"]), tracking["startedAt"], json.dumps(tracking)),
)
def _backfill_request_repairs() -> None:
# Carry existing issue repairs forward once, without depending on the ticket's
# lifetime. Deleting/closing an issue must not restore stale availability.
with _connect() as conn:
rows = conn.execute("""
SELECT a.metadata_json FROM portal_item_activity a
JOIN portal_items p ON p.id = a.item_id
WHERE p.kind = 'issue' AND p.status IN ('in_progress', 'blocked')
AND a.event_type IN ('replacement_started', 'missing_search_started')
AND a.id = (SELECT MAX(b.id) FROM portal_item_activity b
WHERE b.item_id = a.item_id
AND b.event_type IN ('replacement_started', 'missing_search_started'))
""").fetchall()
for (raw,) in rows:
try:
tracking = json.loads(raw or "{}").get("repairTracking")
if isinstance(tracking, dict) and tracking.get("requestId") and tracking.get("startedAt"):
start_request_repair(tracking)
except (TypeError, ValueError):
continue
def get_request_repairs(request_id: str, *, active_only: bool = True) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"SELECT id, tracking_json, completed_at FROM request_repairs WHERE request_id = ?"
+ (" AND completed_at IS NULL" if active_only else "") + " ORDER BY id",
(str(request_id),),
).fetchall()
return [{"id": row[0], **json.loads(row[1]), "completedAt": row[2]} for row in rows]
def complete_request_repair(repair_id: int) -> None:
with _connect() as conn:
conn.execute("UPDATE request_repairs SET completed_at = ? WHERE id = ? AND completed_at IS NULL",
(datetime.now(timezone.utc).isoformat(), repair_id))
def active_repair_request_ids() -> set[str]:
with _connect() as conn:
return {row[0] for row in conn.execute("SELECT DISTINCT request_id FROM request_repairs WHERE completed_at IS NULL")}
def save_snapshot(snapshot: Snapshot) -> None:
@@ -826,15 +889,17 @@ def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str
described honestly in the UI.
"""
with _connect() as conn:
cycle = conn.execute("SELECT MAX(started_at) FROM request_repairs WHERE request_id = ?",
(str(request_id),)).fetchone()[0]
rows = conn.execute(
"""
SELECT created_at, payload_json
FROM snapshots
WHERE request_id = ?
WHERE request_id = ? AND (? IS NULL OR created_at >= ?)
ORDER BY id DESC
LIMIT ?
""",
(request_id, max(1, min(int(limit or 100), 500))),
(request_id, cycle, cycle, max(1, min(int(limit or 100), 500))),
).fetchall()
for created_at, payload_json in rows:
@@ -842,6 +907,10 @@ def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str
payload = json.loads(payload_json)
except (TypeError, ValueError):
continue
# A poll begun before deletion may finish afterwards. Its wall-clock save
# time alone is not evidence that it belongs to the replacement cycle.
if cycle and (payload.get("raw", {}).get("repairCycle") or "") < cycle:
continue
timeline = payload.get("timeline") if isinstance(payload, dict) else None
if not isinstance(timeline, list):
continue
+43 -13
View File
@@ -51,7 +51,11 @@ from ..db import (
record_seerr_media_failure,
clear_seerr_media_failure,
get_request_download_evidence,
start_request_repair,
get_request_repairs,
active_repair_request_ids,
)
from ..services.media_repair import current_cycle_torrents
from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import (
_summarize_qbit,
@@ -1401,6 +1405,7 @@ def _get_recent_from_cache(
status_codes: Optional[list[int]] = None,
) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or []
repairing = active_repair_request_ids()
results = []
since_dt = _parse_iso_datetime(since_iso)
for item in items:
@@ -1414,6 +1419,8 @@ def _get_recent_from_cache(
item_dt = _parse_iso_datetime(candidate)
if not item_dt or item_dt < since_dt:
continue
if str(item.get("request_id")) in repairing:
item = {**item, "status": 5, "repairing": True}
if status_codes and item.get("status") not in status_codes:
continue
results.append(item)
@@ -1862,6 +1869,9 @@ def _record_replacement_activity(
message: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
tracking = (metadata or {}).get("repairTracking")
if event_type in {"replacement_started", "missing_search_started"} and isinstance(tracking, dict):
start_request_repair(tracking)
if not issue:
return
current_status = str(issue.get("status") or "new").strip().lower()
@@ -2183,6 +2193,27 @@ async def action_replace_media(
collector_id: Optional[int] = None
target_episodes: List[Dict[str, int]] = []
jellyfin_baseline: List[Dict[str, Any]] = []
repair_tracking: Dict[str, Any] = {}
def record_cycle() -> None:
repair_tracking.update({
"requestId": request_id,
"actionId": "replace_media",
"mediaType": snapshot.request_type.value,
"collectorId": collector_id,
"originalFileIds": file_ids,
"previousDownloadIds": list(dict.fromkeys(
list(snapshot.raw.get("qbittorrent", {}).get("downloadIds") or [])
+ [str(t.get("hash")) for t in snapshot.raw.get("qbittorrent", {}).get("torrents", []) if t.get("hash")]
)),
"episodes": target_episodes,
"jellyfinBaseline": jellyfin_baseline,
"jellyfinFoundAtStart": bool(snapshot.raw.get("jellyfin", {}).get("catalogFound",
snapshot.raw.get("jellyfin", {}).get("found"))),
"startedAt": datetime.now(timezone.utc).isoformat(),
})
start_request_repair(repair_tracking)
try:
if snapshot.request_type == RequestType.movie:
movie_id = arr_item.get("id")
@@ -2201,6 +2232,7 @@ async def action_replace_media(
if not radarr.configured():
raise HTTPException(status_code=400, detail="Radarr is not configured")
await radarr.monitor_movie(movie_id, True)
await asyncio.to_thread(record_cycle)
await radarr.delete_movie_file(file_ids[0])
await radarr.search(movie_id)
elif snapshot.request_type == RequestType.tv:
@@ -2263,6 +2295,7 @@ async def action_replace_media(
logger.warning("Could not capture Jellyfin episode baseline request_id=%s", request_id)
target_names = [_replacement_file_name(file_data) for file_data in selected_files]
await sonarr.monitor_episodes(episode_ids, True)
await asyncio.to_thread(record_cycle)
for selected_file_id in file_ids:
await sonarr.delete_episode_file(selected_file_id)
await sonarr.search_episodes(episode_ids)
@@ -2318,17 +2351,7 @@ async def action_replace_media(
event_type="replacement_started",
message=message,
metadata={
"repairTracking": {
"requestId": request_id,
"actionId": "replace_media",
"mediaType": snapshot.request_type.value,
"collectorId": collector_id,
"originalFileIds": file_ids,
"episodes": target_episodes,
"jellyfinBaseline": jellyfin_baseline,
"jellyfinFoundAtStart": bool(snapshot.raw.get("jellyfin", {}).get("found")),
"startedAt": datetime.now(timezone.utc).isoformat(),
}
"repairTracking": repair_tracking
},
)
return {
@@ -2688,6 +2711,8 @@ async def get_download_progress(
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
repairs = await asyncio.to_thread(get_request_repairs, request_id, active_only=False)
cycle = repairs[-1]["startedAt"] if repairs else None
evidence = await asyncio.to_thread(get_request_download_evidence, request_id, 20)
historical_torrents = evidence.get("torrents") if isinstance(evidence, dict) else []
hashes: List[str] = []
@@ -2717,7 +2742,7 @@ async def get_download_progress(
logger.warning("Live qBittorrent progress failed request_id=%s: %s", request_id, exc)
raise HTTPException(status_code=502, detail="Live download progress is temporarily unavailable") from exc
torrents = result if isinstance(result, list) else []
torrents = current_cycle_torrents(result, cycle)
for torrent in torrents:
if isinstance(torrent, dict):
torrent["progressPercent"] = _torrent_progress(torrent)
@@ -2739,6 +2764,8 @@ async def get_download_progress(
"summary": message,
"torrents": torrents,
"updated_at": datetime.now(timezone.utc).isoformat(),
"repairCycle": cycle,
"visible": bool(torrents) or bool(evidence.get("observed")),
}
@@ -2879,7 +2906,10 @@ async def recent_requests(
payload_json=json.dumps(details, ensure_ascii=True),
)
status_label = _status_label(status)
if status_label in {"Working on it", "Ready to watch", "Partially ready"}:
if row.get("repairing"):
status = 5
status_label = "Repair in progress"
elif status_label in {"Working on it", "Ready to watch", "Partially ready"}:
is_available = await _request_is_available_in_jellyfin(
jellyfin,
title,
+7 -126
View File
@@ -16,11 +16,10 @@ from ..db import (
list_portal_items,
update_portal_item,
)
from ..clients.jellyfin import JellyfinClient
from ..clients.sonarr import SonarrClient
from ..runtime import get_runtime_settings
from .invite_email import resolve_user_delivery_email, send_generic_email
from .snapshot import build_snapshot
from .media_repair import evaluate_media_repair
logger = logging.getLogger(__name__)
@@ -138,133 +137,15 @@ def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]
return {}, activity
def _positive_ints(value: Any) -> list[int]:
if not isinstance(value, list):
return []
return [
int(item)
for item in value
if isinstance(item, int) and not isinstance(item, bool) and item > 0
]
def _media_signature(item: Any) -> Dict[str, str]:
if not isinstance(item, dict):
return {}
result: Dict[str, str] = {}
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
value = item.get(key)
if isinstance(value, (dict, list)):
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
elif value is not None and str(value).strip():
result[key] = str(value).strip()
return result
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
previous = _media_signature(baseline)
if not previous:
return True
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
request_id = str(tracking.get("requestId") or "").strip()
action_id = str(tracking.get("actionId") or "").strip()
media_type = str(tracking.get("mediaType") or "").strip().lower()
collector_id = tracking.get("collectorId")
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
snapshot = await build_snapshot(request_id)
snapshot = await build_snapshot(str(tracking.get("requestId") or ""))
raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
arr = raw.get("arr") if isinstance(raw.get("arr"), dict) else {}
arr_item = arr.get("item") if isinstance(arr, dict) else None
jellyfin = raw.get("jellyfin") if isinstance(raw.get("jellyfin"), dict) else {}
jellyfin_item = jellyfin.get("item") if isinstance(jellyfin, dict) else None
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
baselines = tracking.get("jellyfinBaseline")
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
found_at_start = tracking.get("jellyfinFoundAtStart") is True
if media_type == "movie":
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
imported = isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
current_signature = _media_signature(jellyfin_item)
if action_id == "replace_media" and found_at_start:
if not baselines or not _signature_changed(current_signature, baselines[0]):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
target_rows = tracking.get("episodes")
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
target_ids = {
int(item["id"])
for item in targets
if isinstance(item.get("id"), int) and int(item["id"]) > 0
}
target_pairs = {
(int(item["seasonNumber"]), int(item["episodeNumber"]))
for item in targets
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if not target_ids or not target_pairs:
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
runtime = get_runtime_settings()
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
episodes = await sonarr.get_episodes(collector_id)
episode_map = {
int(item["id"]): item
for item in episodes
if isinstance(item, dict) and isinstance(item.get("id"), int)
} if isinstance(episodes, list) else {}
imported = all(
episode_id in episode_map
and (
episode_map[episode_id].get("hasFile") is True
or (
isinstance(episode_map[episode_id].get("episodeFileId"), int)
and episode_map[episode_id]["episodeFileId"] > 0
)
)
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
for episode_id in target_ids
jellyfin = dict(raw.get("jellyfin") or {})
jellyfin["found"] = jellyfin.get("catalogFound", jellyfin.get("found"))
return await evaluate_media_repair(
tracking, (raw.get("arr") or {}).get("item"), jellyfin,
episodes=(raw.get("arr") or {}).get("episodes"),
)
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
current_by_pair = {
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
for item in jellyfin_episodes
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
}
if not all(pair in current_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
if action_id == "replace_media" and found_at_start:
baseline_by_pair = {
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
for item in baselines
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if any(pair not in baseline_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
if not all(
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
for pair in target_pairs
):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
def _close_issue(
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict
from ..clients.jellyfin import JellyfinClient
from ..clients.sonarr import SonarrClient
from ..runtime import get_runtime_settings
def current_cycle_torrents(torrents: Any, cycle: str | None) -> list[Dict[str, Any]]:
"""Old seeding jobs are not proof of a replacement download.
A same-hash retry is valid when it is downloading again or was added anew.
Without a completion/add timestamp, a completed legacy job cannot prove that.
"""
rows = [item for item in torrents if isinstance(item, dict)] if isinstance(torrents, list) else []
if not cycle:
return rows
cutoff = datetime.fromisoformat(cycle).timestamp()
def belongs(item: Dict[str, Any]) -> bool:
try:
progress = float(item.get("progress", 0))
completed = float(item.get("completion_on") or 0)
added = float(item.get("added_on") or 0)
except (TypeError, ValueError):
return False
return progress < 1 or max(completed, added) >= cutoff
return [item for item in rows if belongs(item)]
def _positive_ints(value: Any) -> list[int]:
if not isinstance(value, list):
return []
return [
int(item)
for item in value
if isinstance(item, int) and not isinstance(item, bool) and item > 0
]
def _media_signature(item: Any) -> Dict[str, str]:
if not isinstance(item, dict):
return {}
result: Dict[str, str] = {}
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
value = item.get(key)
if isinstance(value, (dict, list)):
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
elif value is not None and str(value).strip():
result[key] = str(value).strip()
return result
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
previous = _media_signature(baseline)
if not previous:
return True
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
async def evaluate_media_repair(
tracking: Dict[str, Any], arr_item: Any, jellyfin: Dict[str, Any],
*, episodes: Any = None,
) -> Dict[str, Any]:
request_id = str(tracking.get("requestId") or "").strip()
action_id = str(tracking.get("actionId") or "").strip()
media_type = str(tracking.get("mediaType") or "").strip().lower()
collector_id = tracking.get("collectorId")
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
jellyfin_item = jellyfin.get("item")
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
baselines = tracking.get("jellyfinBaseline")
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
found_at_start = tracking.get("jellyfinFoundAtStart") is True
if not isinstance(arr_item, dict) or arr_item.get("id") != collector_id:
return {"complete": False, "phase": "collecting", "message": "Waiting for the correct collector record."}
if media_type == "movie":
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
imported = arr_item.get("hasFile") is not False and isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
current_signature = _media_signature(jellyfin_item)
if action_id == "replace_media" and found_at_start:
if not baselines or not _signature_changed(current_signature, baselines[0]):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
target_rows = tracking.get("episodes")
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
target_ids = {
int(item["id"])
for item in targets
if isinstance(item.get("id"), int) and int(item["id"]) > 0
}
target_pairs = {
(int(item["seasonNumber"]), int(item["episodeNumber"]))
for item in targets
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if not target_ids or not target_pairs:
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
runtime = get_runtime_settings()
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if episodes is None:
episodes = await sonarr.get_episodes(collector_id)
episode_map = {
int(item["id"]): item
for item in episodes
if isinstance(item, dict) and isinstance(item.get("id"), int)
} if isinstance(episodes, list) else {}
imported = all(
episode_id in episode_map
and episode_map[episode_id].get("hasFile") is not False
and (
episode_map[episode_id].get("hasFile") is True
or (
isinstance(episode_map[episode_id].get("episodeFileId"), int)
and episode_map[episode_id]["episodeFileId"] > 0
)
)
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
for episode_id in target_ids
)
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
current_by_pair = {
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
for item in jellyfin_episodes
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
}
if not all(pair in current_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
if action_id == "replace_media" and found_at_start:
baseline_by_pair = {
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
for item in baselines
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if any(pair not in baseline_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
if not all(
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
for pair in target_pairs
):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
+126 -3
View File
@@ -19,6 +19,8 @@ from ..db import (
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,
@@ -28,6 +30,7 @@ from ..db import (
)
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
logger = logging.getLogger(__name__)
@@ -975,9 +978,83 @@ def _build_presentation(
}
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)
@@ -1129,6 +1206,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
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
@@ -1168,6 +1246,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
"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
@@ -1276,7 +1356,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
jellyfin_item = item
break
if jellyfin_available and arr_state == "missing" and runtime.jellyfin_sync_to_arr:
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:
@@ -1317,6 +1397,30 @@ async def build_snapshot(request_id: str) -> Snapshot:
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))
@@ -1333,6 +1437,17 @@ async def build_snapshot(request_id: str) -> Snapshot:
request_tag = f"magent-{request_id}"
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
torrent_list = torrents if isinstance(torrents, list) else []
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)
@@ -1476,8 +1591,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
)
)
download_ids = _download_ids(_queue_records(arr_queue))
if download_ids and qbittorrent.configured():
if download_ids and qbittorrent.configured() and qbit_state == "paused":
actions.append(
ActionOption(
id="resume_torrent",
@@ -1506,12 +1620,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
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 {
@@ -1550,6 +1667,12 @@ async def build_snapshot(request_id: str) -> Snapshot:
)
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"])