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
+6 -2
View File
@@ -183,5 +183,9 @@ class ApiClient:
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("PUT", path, payload=payload)
async def delete(self, path: str) -> Optional[Any]:
return await self._request("DELETE", path)
async def delete(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Any]:
return await self._request("DELETE", path, params=params)
+6
View File
@@ -39,6 +39,12 @@ class RadarrClient(ApiClient):
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/moviefile/{movie_file_id}",
params={"deleteFromClient": "true"},
)
async def add_movie(
self,
tmdb_id: int,
+9
View File
@@ -41,6 +41,9 @@ class SonarrClient(ApiClient):
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id})
async def get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
return await self.get(
"/api/v3/release",
@@ -54,6 +57,12 @@ class SonarrClient(ApiClient):
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/episodefile/{episode_file_id}",
params={"deleteFromClient": "true"},
)
async def add_series(
self,
tvdb_id: int,
+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()
+91
View File
@@ -1166,6 +1166,97 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
)
class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
async def test_movie_replacement_validates_file_then_deletes_and_searches(self) -> None:
snapshot = Snapshot(
request_id="3914",
title="Replacement Movie",
request_type=RequestType.movie,
state=NormalizedState.available,
raw={
"arr": {
"item": {
"id": 44,
"movieFile": {
"id": 77,
"relativePath": "Replacement.Movie.1080p.mkv",
},
}
}
},
)
radarr = SimpleNamespace(
configured=lambda: True,
delete_movie_file=AsyncMock(return_value=None),
search=AsyncMock(return_value={"id": 1}),
)
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr",
jellyseerr_api_key="secret",
radarr_base_url="http://radarr",
radarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "RadarrClient", return_value=radarr),
patch.object(requests_router, "save_action"),
):
result = await requests_router.action_replace_media(
"3914",
{"file_id": 77, "confirmed": True},
{"username": "admin", "role": "admin", "auto_search_enabled": True},
)
self.assertEqual(result["status"], "ok")
radarr.delete_movie_file.assert_awaited_once_with(77)
radarr.search.assert_awaited_once_with(44)
async def test_tv_replacement_options_return_only_safe_file_details(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Replacement Series",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 22}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episode_files=AsyncMock(return_value=[{
"id": 88,
"seasonNumber": 2,
"path": "/private/library/Replacement.Series.S02E03.mkv",
"size": 1024,
"quality": {"quality": {"name": "WEBDL-1080p"}},
}]),
get_episodes=AsyncMock(return_value=[{
"id": 101,
"episodeFileId": 88,
"seasonNumber": 2,
"episodeNumber": 3,
}]),
)
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr",
jellyseerr_api_key="secret",
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
):
result = await requests_router.replacement_options(
"3909",
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["files"][0]["name"], "Replacement.Series.S02E03.mkv")
self.assertEqual(result["files"][0]["episodes"], ["S02E03"])
self.assertNotIn("/private/library", str(result))
class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None)