|
|
|
@@ -1758,6 +1758,44 @@ def _missing_episode_ids_by_season(episodes: Any) -> Dict[int, List[int]]:
|
|
|
|
|
return grouped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _replacement_file_name(file_data: Dict[str, Any]) -> str:
|
|
|
|
|
raw = file_data.get("relativePath") or file_data.get("path") or file_data.get("sceneName")
|
|
|
|
|
if isinstance(raw, str) and raw.strip():
|
|
|
|
|
return raw.strip().replace("\\", "/").rsplit("/", 1)[-1]
|
|
|
|
|
return "Managed media file"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _replacement_quality_name(file_data: Dict[str, Any]) -> Optional[str]:
|
|
|
|
|
quality = file_data.get("quality")
|
|
|
|
|
if not isinstance(quality, dict):
|
|
|
|
|
return None
|
|
|
|
|
nested = quality.get("quality")
|
|
|
|
|
if isinstance(nested, dict):
|
|
|
|
|
value = nested.get("name")
|
|
|
|
|
return str(value).strip() if value is not None and str(value).strip() else None
|
|
|
|
|
value = quality.get("name")
|
|
|
|
|
return str(value).strip() if value is not None and str(value).strip() else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _replacement_file_payload(
|
|
|
|
|
file_data: Dict[str, Any],
|
|
|
|
|
*,
|
|
|
|
|
episode_numbers: Optional[List[str]] = None,
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
file_id = file_data.get("id")
|
|
|
|
|
if not isinstance(file_id, int) or file_id <= 0:
|
|
|
|
|
return None
|
|
|
|
|
size = file_data.get("size")
|
|
|
|
|
return {
|
|
|
|
|
"id": file_id,
|
|
|
|
|
"name": _replacement_file_name(file_data),
|
|
|
|
|
"quality": _replacement_quality_name(file_data),
|
|
|
|
|
"size": int(size) if isinstance(size, (int, float)) and size >= 0 else None,
|
|
|
|
|
"season_number": file_data.get("seasonNumber") if isinstance(file_data.get("seasonNumber"), int) else None,
|
|
|
|
|
"episodes": episode_numbers or [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
@@ -1771,6 +1809,209 @@ async def _resolve_root_folder_path(client: Any, root_folder: str, service_name:
|
|
|
|
|
return root_folder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{request_id}/replacement-options")
|
|
|
|
|
async def replacement_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,
|
|
|
|
|
"files": [],
|
|
|
|
|
"message": "This title is not currently linked to a Sonarr/Radarr library item.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
files: List[Dict[str, Any]] = []
|
|
|
|
|
if snapshot.request_type == RequestType.movie:
|
|
|
|
|
movie_file = arr_item.get("movieFile")
|
|
|
|
|
if isinstance(movie_file, dict):
|
|
|
|
|
option = _replacement_file_payload(movie_file)
|
|
|
|
|
if option:
|
|
|
|
|
files.append(option)
|
|
|
|
|
elif snapshot.request_type == RequestType.tv:
|
|
|
|
|
series_id = arr_item.get("id")
|
|
|
|
|
if not isinstance(series_id, int):
|
|
|
|
|
raise HTTPException(status_code=502, detail="Sonarr returned an invalid series record")
|
|
|
|
|
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:
|
|
|
|
|
episode_files, episodes = await asyncio.gather(
|
|
|
|
|
sonarr.get_episode_files(series_id),
|
|
|
|
|
sonarr.get_episodes(series_id),
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("Sonarr replacement options failed request_id=%s error=%s", request_id, exc)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=502,
|
|
|
|
|
detail="Magent could not read the managed episode files from Sonarr.",
|
|
|
|
|
) from exc
|
|
|
|
|
episode_labels: Dict[int, List[str]] = {}
|
|
|
|
|
if isinstance(episodes, list):
|
|
|
|
|
for episode in episodes:
|
|
|
|
|
if not isinstance(episode, dict):
|
|
|
|
|
continue
|
|
|
|
|
file_id = episode.get("episodeFileId")
|
|
|
|
|
season_number = episode.get("seasonNumber")
|
|
|
|
|
episode_number = episode.get("episodeNumber")
|
|
|
|
|
if not all(isinstance(value, int) for value in (file_id, season_number, episode_number)):
|
|
|
|
|
continue
|
|
|
|
|
label = f"S{season_number:02d}E{episode_number:02d}"
|
|
|
|
|
episode_labels.setdefault(file_id, []).append(label)
|
|
|
|
|
if isinstance(episode_files, list):
|
|
|
|
|
for file_data in episode_files:
|
|
|
|
|
if not isinstance(file_data, dict):
|
|
|
|
|
continue
|
|
|
|
|
option = _replacement_file_payload(
|
|
|
|
|
file_data,
|
|
|
|
|
episode_numbers=episode_labels.get(file_data.get("id"), []),
|
|
|
|
|
)
|
|
|
|
|
if option:
|
|
|
|
|
files.append(option)
|
|
|
|
|
files.sort(
|
|
|
|
|
key=lambda item: (
|
|
|
|
|
item.get("season_number") if isinstance(item.get("season_number"), int) else 9999,
|
|
|
|
|
",".join(item.get("episodes") or []),
|
|
|
|
|
str(item.get("name") or ""),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"request_id": request_id,
|
|
|
|
|
"request_type": snapshot.request_type.value,
|
|
|
|
|
"title": snapshot.title,
|
|
|
|
|
"files": files,
|
|
|
|
|
"can_replace": _user_can_use_search_auto(user),
|
|
|
|
|
"message": (
|
|
|
|
|
"Choose the exact managed file to remove and replace."
|
|
|
|
|
if files
|
|
|
|
|
else "Sonarr/Radarr does not currently report a managed file for this title."
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{request_id}/actions/replace")
|
|
|
|
|
async def action_replace_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="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")
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
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"
|
|
|
|
|
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:
|
|
|
|
|
raise HTTPException(status_code=409, detail="The selected movie file is no longer current")
|
|
|
|
|
target_name = _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.search(movie_id)
|
|
|
|
|
elif snapshot.request_type == RequestType.tv:
|
|
|
|
|
series_id = arr_item.get("id")
|
|
|
|
|
if not isinstance(series_id, int):
|
|
|
|
|
raise HTTPException(status_code=502, detail="Sonarr returned an invalid series record")
|
|
|
|
|
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
|
|
|
|
if not sonarr.configured():
|
|
|
|
|
raise HTTPException(status_code=400, detail="Sonarr is not configured")
|
|
|
|
|
episode_files, episodes = await asyncio.gather(
|
|
|
|
|
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")
|
|
|
|
|
episode_ids = [
|
|
|
|
|
episode.get("id")
|
|
|
|
|
for episode in episodes
|
|
|
|
|
if isinstance(episode, dict)
|
|
|
|
|
and episode.get("episodeFileId") == file_id
|
|
|
|
|
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)
|
|
|
|
|
await sonarr.search_episodes(episode_ids)
|
|
|
|
|
else:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Unknown request type")
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.exception("%s media replacement failed request_id=%s file_id=%s", collector, request_id, file_id)
|
|
|
|
|
detail = (
|
|
|
|
|
f"{collector} could not complete the replacement. Check the request action history "
|
|
|
|
|
"before trying again."
|
|
|
|
|
)
|
|
|
|
|
await asyncio.to_thread(
|
|
|
|
|
save_action,
|
|
|
|
|
request_id,
|
|
|
|
|
"replace_media",
|
|
|
|
|
"Replace media file",
|
|
|
|
|
"failed",
|
|
|
|
|
detail,
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(status_code=502, detail=detail) from exc
|
|
|
|
|
|
|
|
|
|
message = f"{collector} removed {target_name} and started a replacement search."
|
|
|
|
|
await asyncio.to_thread(
|
|
|
|
|
save_action,
|
|
|
|
|
request_id,
|
|
|
|
|
"replace_media",
|
|
|
|
|
"Replace media file",
|
|
|
|
|
"ok",
|
|
|
|
|
message,
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"message": message,
|
|
|
|
|
"collector": collector,
|
|
|
|
|
"request_id": request_id,
|
|
|
|
|
"file_id": file_id,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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()
|
|
|
|
|