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