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
+8
View File
@@ -11,6 +11,7 @@ _SERVICE_NAMES = {
"JellyseerrClient": "Seerr",
"SonarrClient": "Sonarr",
"RadarrClient": "Radarr",
"BazarrClient": "Bazarr",
"ProwlarrClient": "Prowlarr",
"JellyfinClient": "Jellyfin",
"QBittorrentClient": "qBittorrent",
@@ -184,6 +185,11 @@ def _operation_result_message(
else "Prowlarr did not find any possible releases."
)
if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
target = "movie" if "/movies/" in normalized_path else "selected episode"
language = str((params or {}).get("language") or "the requested language").upper()
return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
if normalized_method == "GET":
return f"{service} completed the check successfully."
if normalized_method == "POST":
@@ -234,6 +240,8 @@ def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]
return f"Checking releases through {service}", f"{service} returned release information"
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
return f"Sending a command to {service}", f"{service} accepted the command"
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
if service == "Prowlarr" and "/health" in normalized_path:
return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health"
return f"Contacting {service}", f"{service} responded"
+48
View File
@@ -0,0 +1,48 @@
from typing import Any, Optional
from .base import ApiClient
class BazarrClient(ApiClient):
async def get_system_status(self) -> Optional[Any]:
return await self._request("GET", "/api/system/status")
async def search_movie_subtitles(
self,
radarr_id: int,
*,
language: str,
forced: bool = False,
) -> Optional[Any]:
return await self._request(
"PATCH",
"/api/movies/subtitles",
params={
"radarrid": radarr_id,
"language": language,
"forced": str(forced).lower(),
"hi": "false",
},
timeout_seconds=90.0,
)
async def search_episode_subtitles(
self,
series_id: int,
episode_id: int,
*,
language: str,
forced: bool = False,
) -> Optional[Any]:
return await self._request(
"PATCH",
"/api/episodes/subtitles",
params={
"seriesid": series_id,
"episodeid": episode_id,
"language": language,
"forced": str(forced).lower(),
"hi": "false",
},
timeout_seconds=90.0,
)
+10
View File
@@ -305,6 +305,16 @@ class Settings(BaseSettings):
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
bazarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
)
bazarr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
)
bazarr_default_language: str = Field(
default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
)
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
)
+5
View File
@@ -135,6 +135,7 @@ SENSITIVE_KEYS = {
"jellyfin_api_key",
"sonarr_api_key",
"radarr_api_key",
"bazarr_api_key",
"prowlarr_api_key",
"qbittorrent_password",
}
@@ -150,6 +151,7 @@ URL_SETTING_KEYS = {
"jellyfin_public_url",
"sonarr_base_url",
"radarr_base_url",
"bazarr_base_url",
"prowlarr_base_url",
"qbittorrent_base_url",
}
@@ -216,6 +218,9 @@ SETTING_KEYS: List[str] = [
"radarr_quality_profile_id",
"radarr_root_folder",
"radarr_qbittorrent_category",
"bazarr_base_url",
"bazarr_api_key",
"bazarr_default_language",
"prowlarr_base_url",
"prowlarr_api_key",
"qbittorrent_base_url",
+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()
+15
View File
@@ -7,6 +7,7 @@ from ..runtime import get_runtime_settings
from ..clients.jellyseerr import JellyseerrClient
from ..clients.sonarr import SonarrClient
from ..clients.radarr import RadarrClient
from ..clients.bazarr import BazarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient
@@ -61,6 +62,7 @@ async def services_status() -> Dict[str, Any]:
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
@@ -89,6 +91,13 @@ async def services_status() -> Dict[str, Any]:
radarr.get_system_status,
)
)
services.append(
await _check(
"Bazarr",
bazarr.configured() and bool(runtime.bazarr_api_key),
bazarr.get_system_status,
)
)
prowlarr_status = await _check(
"Prowlarr",
prowlarr.configured(),
@@ -124,6 +133,7 @@ async def test_service(service: str) -> Dict[str, Any]:
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
@@ -144,6 +154,11 @@ async def test_service(service: str) -> Dict[str, Any]:
),
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"bazarr": (
"Bazarr",
bazarr.configured() and bool(runtime.bazarr_api_key),
bazarr.get_system_status,
),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
}
+163
View File
@@ -254,6 +254,20 @@ class OperationMessageTests(unittest.TestCase):
"The title is not currently available in Jellyfin.",
)
def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None:
message = _operation_result_message(
"Bazarr",
"PATCH",
"/api/episodes/subtitles",
{"status": True},
params={"language": "en", "episodeid": 42},
)
self.assertEqual(
message,
"Bazarr accepted a fresh EN subtitle search for the selected episode.",
)
def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None:
self.assertEqual(
_operation_error_message("Radarr", 500),
@@ -1359,6 +1373,155 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result["files"][0]["episodes"], ["S02E03"])
self.assertNotIn("/private/library", str(result))
async def test_issue_options_mark_released_missing_episodes_as_best_fit(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Target Series",
request_type=RequestType.tv,
state=NormalizedState.downloading,
raw={"arr": {"item": {"id": 22}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episodes=AsyncMock(return_value=[
{
"id": 101,
"seasonNumber": 1,
"episodeNumber": 1,
"title": "Collected",
"monitored": True,
"hasFile": True,
"episodeFileId": 88,
"airDateUtc": "2020-01-01T00:00:00Z",
},
{
"id": 102,
"seasonNumber": 1,
"episodeNumber": 2,
"title": "Missing",
"monitored": True,
"hasFile": False,
"episodeFileId": 0,
"airDateUtc": "2020-01-08T00:00:00Z",
},
]),
)
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
):
result = await requests_router.issue_target_options(
"3909",
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["seasons"][0]["missing_count"], 1)
self.assertTrue(result["seasons"][0]["best_fit"])
missing = next(item for item in result["episodes"] if item["id"] == 102)
self.assertTrue(missing["missing"])
self.assertTrue(missing["best_fit"])
collected = next(item for item in result["episodes"] if item["id"] == 101)
self.assertEqual(collected["file_id"], 88)
self.assertNotIn("file_name", collected)
self.assertNotIn("quality", collected)
async def test_tv_replacement_accepts_multiple_selected_episode_files(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Replacement Series",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 22}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episode_files=AsyncMock(return_value=[
{"id": 88, "relativePath": "S01E01.mkv"},
{"id": 89, "relativePath": "S01E02.mkv"},
]),
get_episodes=AsyncMock(return_value=[
{"id": 101, "episodeFileId": 88},
{"id": 102, "episodeFileId": 89},
]),
delete_episode_file=AsyncMock(return_value=None),
search_episodes=AsyncMock(return_value={"id": 1}),
)
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
patch.object(requests_router, "save_action"),
patch.object(requests_router, "get_portal_item", return_value={
"id": 12,
"kind": "issue",
"external_ref": "/requests/3909",
"created_by_username": "viewer",
}),
patch.object(requests_router, "add_portal_item_activity"),
):
result = await requests_router.action_replace_media(
"3909",
{"file_ids": [88, 89], "confirmed": True, "issue_id": 12},
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["file_ids"], [88, 89])
self.assertEqual(sonarr.delete_episode_file.await_count, 2)
sonarr.search_episodes.assert_awaited_once_with([101, 102])
async def test_movie_subtitle_issue_starts_bazarr_search_without_replacement(self) -> None:
snapshot = Snapshot(
request_id="3914",
title="Subtitle Movie",
request_type=RequestType.movie,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 44, "movieFile": {"id": 77}}}},
)
bazarr = SimpleNamespace(
configured=lambda: True,
search_movie_subtitles=AsyncMock(return_value={"status": True}),
)
runtime = SimpleNamespace(
bazarr_base_url="http://bazarr",
bazarr_api_key="secret",
bazarr_default_language="en",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "BazarrClient", return_value=bazarr),
patch.object(requests_router, "save_action"),
patch.object(requests_router, "get_portal_item", return_value={
"id": 12,
"kind": "issue",
"external_ref": "/requests/3914",
"created_by_username": "viewer",
}),
patch.object(requests_router, "add_portal_item_activity") as add_activity,
):
result = await requests_router.action_repair_subtitles(
"3914",
{"issue_id": 12, "episode_ids": [], "forced": True},
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["status"], "ok")
bazarr.search_movie_subtitles.assert_awaited_once_with(44, language="en", forced=True)
self.assertEqual(add_activity.call_args.kwargs["event_type"], "subtitle_repair_started")
class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None: