Streamline issue reporting and automate repairs
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s

This commit is contained in:
2026-09-01 13:44:13 +12:00
parent 2adbed7259
commit e58614305e
12 changed files with 1223 additions and 333 deletions
+366 -23
View File
@@ -14,6 +14,7 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient
from ..clients.bazarr import BazarrClient
from ..ai.triage import triage_snapshot
from ..auth import get_current_user
from ..runtime import get_runtime_settings
@@ -1838,6 +1839,77 @@ def _record_replacement_activity(
)
def _released_episode(episode: Dict[str, Any]) -> bool:
if episode.get("hasFile") is True:
return True
raw_date = episode.get("airDateUtc") or episode.get("airDate")
if not isinstance(raw_date, str) or not raw_date.strip():
return False
try:
parsed = datetime.fromisoformat(raw_date.strip().replace("Z", "+00:00"))
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc) <= datetime.now(timezone.utc)
def _issue_episode_payloads(
episodes: Any,
) -> List[Dict[str, Any]]:
results: List[Dict[str, Any]] = []
if not isinstance(episodes, list):
return results
for episode in episodes:
if not isinstance(episode, dict):
continue
episode_id = episode.get("id")
season_number = episode.get("seasonNumber")
episode_number = episode.get("episodeNumber")
if not all(isinstance(value, int) for value in (episode_id, season_number, episode_number)):
continue
released = _released_episode(episode)
has_file = episode.get("hasFile") is True or (
isinstance(episode.get("episodeFileId"), int) and episode.get("episodeFileId") > 0
)
monitored = episode.get("monitored") is not False
results.append(
{
"id": episode_id,
"season_number": season_number,
"episode_number": episode_number,
"code": f"S{season_number:02d}E{episode_number:02d}",
"title": str(episode.get("title") or f"Episode {episode_number}").strip(),
"released": released,
"monitored": monitored,
"has_file": has_file,
"missing": released and monitored and not has_file,
"best_fit": released and monitored and not has_file,
"file_id": episode.get("episodeFileId") if has_file else None,
}
)
results.sort(key=lambda item: (item["season_number"], item["episode_number"]))
return results
def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
grouped: Dict[int, List[Dict[str, Any]]] = {}
for episode in episodes:
grouped.setdefault(int(episode["season_number"]), []).append(episode)
return [
{
"season_number": season_number,
"label": "Specials" if season_number == 0 else f"Season {season_number}",
"episode_count": len(items),
"available_count": sum(1 for item in items if item["has_file"]),
"missing_count": sum(1 for item in items if item["missing"]),
"best_fit": any(item["best_fit"] for item in items),
}
for season_number, items in sorted(grouped.items())
if any(item["released"] for item in items)
]
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
if root_folder.isdigit():
folders = await client.get_root_folders()
@@ -1851,6 +1923,80 @@ async def _resolve_root_folder_path(client: Any, root_folder: str, service_name:
return root_folder
@router.get("/{request_id}/issue-options")
async def issue_target_options(
request_id: str,
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict):
return {
"request_id": request_id,
"request_type": snapshot.request_type.value,
"title": snapshot.title,
"collector_id": None,
"movie": None,
"seasons": [],
"episodes": [],
"can_act": False,
"message": "This title is not currently linked to Sonarr or Radarr.",
}
collector_id = arr_item.get("id")
if not isinstance(collector_id, int):
raise HTTPException(status_code=502, detail="Sonarr/Radarr returned an invalid media record")
if snapshot.request_type == RequestType.movie:
movie_file = arr_item.get("movieFile") if isinstance(arr_item.get("movieFile"), dict) else None
return {
"request_id": request_id,
"request_type": "movie",
"title": snapshot.title,
"collector_id": collector_id,
"movie": {
"selected_label": snapshot.title,
"has_file": bool(movie_file),
"missing": not bool(movie_file),
"best_fit": not bool(movie_file),
"file_id": movie_file.get("id") if movie_file else None,
},
"seasons": [],
"episodes": [],
"can_act": _user_can_use_search_auto(user),
"message": "Choose the movie to continue.",
}
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not sonarr.configured():
raise HTTPException(status_code=400, detail="Sonarr is not configured")
try:
episodes = await sonarr.get_episodes(collector_id)
except Exception as exc:
logger.warning("Sonarr issue options failed request_id=%s error=%s", request_id, exc)
raise HTTPException(
status_code=502,
detail="Magent could not read the seasons and episodes from Sonarr.",
) from exc
episode_options = _issue_episode_payloads(episodes)
return {
"request_id": request_id,
"request_type": "tv",
"title": snapshot.title,
"collector_id": collector_id,
"movie": None,
"seasons": _issue_season_payloads(episode_options),
"episodes": episode_options,
"can_act": _user_can_use_search_auto(user),
"message": "Choose a season, then select every affected episode.",
}
@router.get("/{request_id}/replacement-options")
async def replacement_options(
request_id: str,
@@ -1954,9 +2100,18 @@ async def action_replace_media(
raise HTTPException(status_code=403, detail="Media replacement is disabled for this user")
if payload.get("confirmed") is not True:
raise HTTPException(status_code=400, detail="Replacement confirmation is required")
file_id = payload.get("file_id")
if not isinstance(file_id, int) or file_id <= 0:
raise HTTPException(status_code=400, detail="A valid managed file is required")
raw_file_ids = payload.get("file_ids")
if raw_file_ids is None:
raw_file_ids = [payload.get("file_id")]
if not isinstance(raw_file_ids, list):
raise HTTPException(status_code=400, detail="Managed files must be supplied as a list")
file_ids = list(dict.fromkeys(
value
for value in raw_file_ids
if isinstance(value, int) and not isinstance(value, bool) and value > 0
))
if not file_ids or len(file_ids) != len(raw_file_ids) or len(file_ids) > 100:
raise HTTPException(status_code=400, detail="Choose between 1 and 100 valid managed files")
linked_issue = _linked_issue_for_replacement(
payload.get("issue_id"),
request_id=request_id,
@@ -1973,20 +2128,20 @@ async def action_replace_media(
raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
target_name = "managed media file"
target_names: List[str] = []
try:
if snapshot.request_type == RequestType.movie:
movie_id = arr_item.get("id")
movie_file = arr_item.get("movieFile")
if not isinstance(movie_id, int) or not isinstance(movie_file, dict):
raise HTTPException(status_code=409, detail="Radarr does not report a replaceable movie file")
if movie_file.get("id") != file_id:
if len(file_ids) != 1 or movie_file.get("id") != file_ids[0]:
raise HTTPException(status_code=409, detail="The selected movie file is no longer current")
target_name = _replacement_file_name(movie_file)
target_names = [_replacement_file_name(movie_file)]
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not radarr.configured():
raise HTTPException(status_code=400, detail="Radarr is not configured")
await radarr.delete_movie_file(file_id)
await radarr.delete_movie_file(file_ids[0])
await radarr.search(movie_id)
elif snapshot.request_type == RequestType.tv:
series_id = arr_item.get("id")
@@ -1999,27 +2154,25 @@ async def action_replace_media(
sonarr.get_episode_files(series_id),
sonarr.get_episodes(series_id),
)
selected_file = next(
(
item
for item in episode_files
if isinstance(item, dict) and item.get("id") == file_id
),
None,
) if isinstance(episode_files, list) else None
if not isinstance(selected_file, dict):
raise HTTPException(status_code=409, detail="The selected episode file is no longer current")
selected_files = [
item
for item in episode_files
if isinstance(item, dict) and item.get("id") in file_ids
] if isinstance(episode_files, list) else []
if len(selected_files) != len(file_ids):
raise HTTPException(status_code=409, detail="One or more selected episode files are no longer current")
episode_ids = [
episode.get("id")
for episode in episodes
if isinstance(episode, dict)
and episode.get("episodeFileId") == file_id
and episode.get("episodeFileId") in file_ids
and isinstance(episode.get("id"), int)
] if isinstance(episodes, list) else []
if not episode_ids:
raise HTTPException(status_code=409, detail="Sonarr could not match episodes to this file")
target_name = _replacement_file_name(selected_file)
await sonarr.delete_episode_file(file_id)
target_names = [_replacement_file_name(file_data) for file_data in selected_files]
for selected_file_id in file_ids:
await sonarr.delete_episode_file(selected_file_id)
await sonarr.search_episodes(episode_ids)
else:
raise HTTPException(status_code=400, detail="Unknown request type")
@@ -2032,7 +2185,7 @@ async def action_replace_media(
)
raise
except Exception as exc:
logger.exception("%s media replacement failed request_id=%s file_id=%s", collector, request_id, file_id)
logger.exception("%s media replacement failed request_id=%s file_ids=%s", collector, request_id, file_ids)
detail = (
f"{collector} could not complete the replacement. Check the request action history "
"before trying again."
@@ -2053,7 +2206,8 @@ async def action_replace_media(
)
raise HTTPException(status_code=502, detail=detail) from exc
message = f"{collector} removed {target_name} and started a replacement search."
file_label = "the selected managed file" if len(target_names) == 1 else f"{len(target_names)} selected managed files"
message = f"{collector} removed {file_label} and started a replacement search."
await asyncio.to_thread(
save_action,
request_id,
@@ -2073,10 +2227,199 @@ async def action_replace_media(
"message": message,
"collector": collector,
"request_id": request_id,
"file_id": file_id,
"file_ids": file_ids,
}
def _positive_id_list(
value: Any,
*,
field: str,
maximum: int = 200,
minimum: int = 1,
) -> List[int]:
if value is None:
return []
if not isinstance(value, list):
raise HTTPException(status_code=400, detail=f"{field} must be a list")
normalized = list(
dict.fromkeys(
item
for item in value
if isinstance(item, int) and not isinstance(item, bool) and item >= minimum
)
)
if len(normalized) != len(value) or len(normalized) > maximum:
raise HTTPException(status_code=400, detail=f"Choose up to {maximum} valid {field.replace('_', ' ')}")
return normalized
@router.post("/{request_id}/actions/search-missing")
async def action_search_missing_media(
request_id: str,
payload: Dict[str, Any],
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Collection searches are disabled for this user")
linked_issue = _linked_issue_for_replacement(
payload.get("issue_id"), request_id=request_id, user=user
)
episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids")
season_numbers = _positive_id_list(
payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0
)
runtime = get_runtime_settings()
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
collector_id = int(arr_item["id"])
try:
if snapshot.request_type == RequestType.movie:
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not radarr.configured():
raise HTTPException(status_code=400, detail="Radarr is not configured")
await radarr.search(collector_id)
message = "Radarr started searching for the missing movie."
searched_ids: List[int] = []
else:
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not sonarr.configured():
raise HTTPException(status_code=400, detail="Sonarr is not configured")
episodes = await sonarr.get_episodes(collector_id)
if not isinstance(episodes, list):
raise HTTPException(status_code=502, detail="Sonarr did not return an episode list")
episode_map = {
int(item["id"]): item
for item in episodes
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if episode_ids:
if any(item_id not in episode_map for item_id in episode_ids):
raise HTTPException(status_code=409, detail="One or more selected episodes are no longer in Sonarr")
searched_ids = episode_ids
else:
searched_ids = [
item_id
for item_id, episode in episode_map.items()
if _released_episode(episode)
and episode.get("monitored") is not False
and not (
episode.get("hasFile") is True
or (isinstance(episode.get("episodeFileId"), int) and episode.get("episodeFileId") > 0)
)
and (not season_numbers or episode.get("seasonNumber") in season_numbers)
]
if searched_ids:
await sonarr.search_episodes(searched_ids)
message = f"Sonarr started searching for {len(searched_ids)} selected missing episode(s)."
else:
await sonarr.search(collector_id)
message = "Sonarr refreshed the series and started a full missing-episode search."
except HTTPException as exc:
_record_replacement_activity(
linked_issue,
user=user,
event_type="missing_search_failed",
message=f"The missing-content search could not start: {exc.detail}",
)
raise
except Exception as exc:
logger.exception("missing content search failed request_id=%s", request_id)
detail = "Sonarr/Radarr could not start the missing-content search."
_record_replacement_activity(
linked_issue, user=user, event_type="missing_search_failed", message=detail
)
raise HTTPException(status_code=502, detail=detail) from exc
await asyncio.to_thread(
save_action, request_id, "search_missing", "Search for missing content", "ok", message
)
_record_replacement_activity(
linked_issue, user=user, event_type="missing_search_started", message=message
)
return {"status": "ok", "message": message, "episode_ids": searched_ids}
@router.post("/{request_id}/actions/repair-subtitles")
async def action_repair_subtitles(
request_id: str,
payload: Dict[str, Any],
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Subtitle repairs are disabled for this user")
linked_issue = _linked_issue_for_replacement(
payload.get("issue_id"), request_id=request_id, user=user
)
episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100)
forced = payload.get("forced") is True
runtime = get_runtime_settings()
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
if not bazarr.configured() or not runtime.bazarr_api_key:
raise HTTPException(status_code=400, detail="Bazarr is not configured")
language = str(runtime.bazarr_default_language or "en").strip().lower() or "en"
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
collector_id = int(arr_item["id"])
try:
if snapshot.request_type == RequestType.movie:
await bazarr.search_movie_subtitles(
collector_id, language=language, forced=forced
)
repaired_count = 1
message = f"Bazarr started a fresh {language.upper()} subtitle search for the movie."
else:
if not episode_ids:
raise HTTPException(status_code=400, detail="Choose at least one episode")
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
episodes = await sonarr.get_episodes(collector_id)
valid_ids = {
int(item["id"])
for item in episodes
if isinstance(item, dict) and isinstance(item.get("id"), int)
} if isinstance(episodes, list) else set()
if any(episode_id not in valid_ids for episode_id in episode_ids):
raise HTTPException(status_code=409, detail="One or more selected episodes are no longer in Sonarr")
for episode_id in episode_ids:
await bazarr.search_episode_subtitles(
collector_id,
episode_id,
language=language,
forced=forced,
)
repaired_count = len(episode_ids)
message = f"Bazarr started fresh {language.upper()} subtitle searches for {repaired_count} episode(s)."
except HTTPException as exc:
_record_replacement_activity(
linked_issue,
user=user,
event_type="subtitle_repair_failed",
message=f"The subtitle repair could not start: {exc.detail}",
)
raise
except Exception as exc:
logger.exception("Bazarr subtitle repair failed request_id=%s", request_id)
detail = "Bazarr could not start the subtitle repair."
_record_replacement_activity(
linked_issue, user=user, event_type="subtitle_repair_failed", message=detail
)
raise HTTPException(status_code=502, detail=detail) from exc
await asyncio.to_thread(
save_action, request_id, "repair_subtitles", "Repair subtitles", "ok", message
)
_record_replacement_activity(
linked_issue, user=user, event_type="subtitle_repair_started", message=message
)
return {"status": "ok", "message": message, "count": repaired_count}
@router.get("/{request_id}/snapshot", response_model=Snapshot)
async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot:
runtime = get_runtime_settings()