Add exact media replacement from issues
Magent CI/CD / verify (push) Canceled after 6m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-08-31 22:05:11 +12:00
parent f8770cb44a
commit ec8145a58a
7 changed files with 979 additions and 34 deletions
+241
View File
@@ -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()