Streamline issue reporting and automate repairs
This commit is contained in:
+366
-23
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user