|
|
|
@@ -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()
|
|
|
|
|