Add exact media replacement from issues
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user