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]:
|
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
return await self._request("PUT", path, payload=payload)
|
return await self._request("PUT", path, payload=payload)
|
||||||
|
|
||||||
async def delete(self, path: str) -> Optional[Any]:
|
async def delete(
|
||||||
return await self._request("DELETE", path)
|
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]]:
|
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
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(
|
async def add_movie(
|
||||||
self,
|
self,
|
||||||
tmdb_id: int,
|
tmdb_id: int,
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class SonarrClient(ApiClient):
|
|||||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
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]:
|
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
|
||||||
return await self.get(
|
return await self.get(
|
||||||
"/api/v3/release",
|
"/api/v3/release",
|
||||||
@@ -54,6 +57,12 @@ class SonarrClient(ApiClient):
|
|||||||
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
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})
|
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(
|
async def add_series(
|
||||||
self,
|
self,
|
||||||
tvdb_id: int,
|
tvdb_id: int,
|
||||||
|
|||||||
@@ -1758,6 +1758,44 @@ def _missing_episode_ids_by_season(episodes: Any) -> Dict[int, List[int]]:
|
|||||||
return grouped
|
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:
|
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
|
||||||
if root_folder.isdigit():
|
if root_folder.isdigit():
|
||||||
folders = await client.get_root_folders()
|
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
|
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)
|
@router.get("/{request_id}/snapshot", response_model=Snapshot)
|
||||||
async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot:
|
async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot:
|
||||||
runtime = get_runtime_settings()
|
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):
|
class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None)
|
portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None)
|
||||||
|
|||||||
@@ -2743,6 +2743,204 @@ button:disabled,
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-media-finder {
|
||||||
|
display: grid;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-search-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-search-row button {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-results {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 410px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(255, 255, 255, 0.022);
|
||||||
|
color: var(--ops-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-result:hover {
|
||||||
|
border-color: rgba(126, 215, 255, 0.42);
|
||||||
|
background: rgba(14, 165, 233, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-result > span:last-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-result small,
|
||||||
|
.issue-media-result b,
|
||||||
|
.issue-selected-media small {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-result b {
|
||||||
|
color: var(--request-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-poster {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 54px;
|
||||||
|
height: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: calc(var(--ops-radius) - 2px);
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-poster img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-media-poster i {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-style: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-selected-media {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid rgba(72, 224, 178, 0.34);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(72, 224, 178, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-selected-media > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(126, 215, 255, 0.25);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(14, 165, 233, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-picker > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-picker h3,
|
||||||
|
.issue-file-picker p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-picker p {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
max-height: 340px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-list > label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(255, 255, 255, 0.022);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-list > label.is-selected {
|
||||||
|
border-color: rgba(72, 224, 178, 0.45);
|
||||||
|
background: rgba(72, 224, 178, 0.055);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-list > label > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-list strong,
|
||||||
|
.issue-file-list small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-file-list small,
|
||||||
|
.issue-file-list b {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-replacement-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid rgba(255, 192, 84, 0.36);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(255, 192, 84, 0.055);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-replacement-toggle > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-replacement-toggle small {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.69rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.media-status-check {
|
.media-status-check {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 13px;
|
gap: 13px;
|
||||||
@@ -2859,6 +3057,22 @@ button:disabled,
|
|||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issue-linked-request {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(126, 215, 255, 0.28);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(14, 165, 233, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-linked-request > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.issue-portal-page .portal-item-row p {
|
.issue-portal-page .portal-item-row p {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2880,9 +3094,15 @@ button:disabled,
|
|||||||
.issue-flow { padding: 14px; }
|
.issue-flow { padding: 14px; }
|
||||||
.issue-category-grid,
|
.issue-category-grid,
|
||||||
.issue-question-grid,
|
.issue-question-grid,
|
||||||
|
.issue-media-results,
|
||||||
.media-status-metrics { grid-template-columns: 1fr; }
|
.media-status-metrics { grid-template-columns: 1fr; }
|
||||||
.issue-field-span-2 { grid-column: span 1; }
|
.issue-field-span-2 { grid-column: span 1; }
|
||||||
.issue-category-card { min-height: 0; }
|
.issue-category-card { min-height: 0; }
|
||||||
.issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); }
|
.issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); }
|
||||||
.issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
|
.issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
|
||||||
|
.issue-media-search-row { grid-template-columns: 1fr; }
|
||||||
|
.issue-selected-media { align-items: stretch; flex-direction: column; }
|
||||||
|
.issue-linked-request { align-items: stretch; flex-direction: column; }
|
||||||
|
.issue-file-list > label { grid-template-columns: auto minmax(0, 1fr); }
|
||||||
|
.issue-file-list > label > b { grid-column: 2; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ type MediaServerStatus = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReplacementFile = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
quality?: string | null
|
||||||
|
size?: number | null
|
||||||
|
season_number?: number | null
|
||||||
|
episodes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
const ISSUE_CATEGORIES: Array<{
|
const ISSUE_CATEGORIES: Array<{
|
||||||
id: IssueCategoryId
|
id: IssueCategoryId
|
||||||
marker: string
|
marker: string
|
||||||
@@ -265,6 +274,13 @@ const formatDate = (value?: string | null) => {
|
|||||||
return parsed.toLocaleString()
|
return parsed.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (value?: number | null) => {
|
||||||
|
if (!value || value <= 0) return 'Size unavailable'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
|
||||||
|
return `${(value / 1024 ** unitIndex).toFixed(unitIndex >= 3 ? 1 : 0)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
const toPositiveInt = (value: string) => {
|
const toPositiveInt = (value: string) => {
|
||||||
const parsed = Number.parseInt(value, 10)
|
const parsed = Number.parseInt(value, 10)
|
||||||
if (Number.isNaN(parsed) || parsed <= 0) return null
|
if (Number.isNaN(parsed) || parsed <= 0) return null
|
||||||
@@ -338,6 +354,17 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
|
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
|
||||||
const [mediaServerChecking, setMediaServerChecking] = useState(false)
|
const [mediaServerChecking, setMediaServerChecking] = useState(false)
|
||||||
const [mediaServerError, setMediaServerError] = useState<string | null>(null)
|
const [mediaServerError, setMediaServerError] = useState<string | null>(null)
|
||||||
|
const [issueMediaQuery, setIssueMediaQuery] = useState('')
|
||||||
|
const [issueMediaSearching, setIssueMediaSearching] = useState(false)
|
||||||
|
const [issueMediaResults, setIssueMediaResults] = useState<DiscoveryResult[]>([])
|
||||||
|
const [issueSelectedMedia, setIssueSelectedMedia] = useState<DiscoveryResult | null>(null)
|
||||||
|
const [replacementFiles, setReplacementFiles] = useState<ReplacementFile[]>([])
|
||||||
|
const [replacementFilesLoading, setReplacementFilesLoading] = useState(false)
|
||||||
|
const [replacementFilesMessage, setReplacementFilesMessage] = useState<string | null>(null)
|
||||||
|
const [replacementFileFilter, setReplacementFileFilter] = useState('')
|
||||||
|
const [selectedReplacementFileId, setSelectedReplacementFileId] = useState<number | null>(null)
|
||||||
|
const [startReplacement, setStartReplacement] = useState(false)
|
||||||
|
const [replacementAllowed, setReplacementAllowed] = useState(true)
|
||||||
|
|
||||||
const isAdmin = me?.role === 'admin'
|
const isAdmin = me?.role === 'admin'
|
||||||
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
|
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
|
||||||
@@ -348,7 +375,21 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const issueNeedsMediaTitle =
|
const issueNeedsMediaTitle =
|
||||||
Boolean(issueCategory) &&
|
Boolean(issueCategory) &&
|
||||||
issueCategory !== 'service_unavailable' &&
|
issueCategory !== 'service_unavailable' &&
|
||||||
!(issueCategory === 'playback' && issueScope === 'everything')
|
!(issueCategory === 'playback' && issueScope !== 'one_title')
|
||||||
|
const issueSupportsReplacement =
|
||||||
|
issueCategory === 'broken_media' ||
|
||||||
|
issueCategory === 'audio' ||
|
||||||
|
issueCategory === 'subtitle' ||
|
||||||
|
(issueCategory === 'playback' && issueScope === 'one_title')
|
||||||
|
const selectedReplacementFile =
|
||||||
|
replacementFiles.find((file) => file.id === selectedReplacementFileId) ?? null
|
||||||
|
const visibleReplacementFiles = replacementFiles.filter((file) => {
|
||||||
|
const query = replacementFileFilter.trim().toLowerCase()
|
||||||
|
if (!query) return true
|
||||||
|
return [file.name, file.quality, ...(file.episodes ?? [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(query))
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -594,6 +635,129 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadReplacementFiles = async (media: DiscoveryResult) => {
|
||||||
|
setReplacementFiles([])
|
||||||
|
setSelectedReplacementFileId(null)
|
||||||
|
setStartReplacement(false)
|
||||||
|
setReplacementAllowed(true)
|
||||||
|
setReplacementFileFilter('')
|
||||||
|
if (!media.requestId) {
|
||||||
|
setReplacementFilesMessage(
|
||||||
|
'This title is not linked to an existing Magent request, so there is no managed file to replace.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setReplacementFilesLoading(true)
|
||||||
|
setReplacementFilesMessage(null)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(
|
||||||
|
`${getApiBase()}/requests/${media.requestId}/replacement-options`
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Could not load the managed files from Sonarr/Radarr.')
|
||||||
|
}
|
||||||
|
const payload = await response.json()
|
||||||
|
const files = Array.isArray(payload?.files) ? payload.files as ReplacementFile[] : []
|
||||||
|
setReplacementFiles(files)
|
||||||
|
setReplacementAllowed(payload?.can_replace !== false)
|
||||||
|
setReplacementFilesMessage(
|
||||||
|
payload?.can_replace === false
|
||||||
|
? 'Your account can report this file, but automatic replacement is disabled.'
|
||||||
|
: payload?.message ?? null
|
||||||
|
)
|
||||||
|
if (files.length === 1 && payload?.can_replace !== false) {
|
||||||
|
setSelectedReplacementFileId(files[0].id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setReplacementFilesMessage(
|
||||||
|
err instanceof Error ? err.message : 'Could not load the managed files from Sonarr/Radarr.'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setReplacementFilesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchIssueMedia = async (event?: React.FormEvent) => {
|
||||||
|
event?.preventDefault()
|
||||||
|
const query = issueMediaQuery.trim()
|
||||||
|
if (!query) {
|
||||||
|
setError('Enter the movie or TV show you are having trouble with.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIssueMediaSearching(true)
|
||||||
|
setError(null)
|
||||||
|
setIssueMediaResults([])
|
||||||
|
setIssueSelectedMedia(null)
|
||||||
|
setIssueMediaTitle('')
|
||||||
|
setReplacementFiles([])
|
||||||
|
setSelectedReplacementFileId(null)
|
||||||
|
setStartReplacement(false)
|
||||||
|
setReplacementAllowed(true)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(
|
||||||
|
`${getApiBase()}/requests/search?query=${encodeURIComponent(query)}`
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('Magent could not search the media catalogue.')
|
||||||
|
}
|
||||||
|
const payload = await response.json()
|
||||||
|
const results: DiscoveryResult[] = Array.isArray(payload?.results)
|
||||||
|
? payload.results
|
||||||
|
.filter((item: any) => item?.type === 'movie' || item?.type === 'tv')
|
||||||
|
.map((item: any) => ({
|
||||||
|
title: item?.title ?? 'Untitled',
|
||||||
|
year: typeof item?.year === 'number' ? item.year : null,
|
||||||
|
type: item.type,
|
||||||
|
tmdbId: typeof item?.tmdbId === 'number' ? item.tmdbId : null,
|
||||||
|
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
|
||||||
|
statusLabel: item?.statusLabel ?? null,
|
||||||
|
status: typeof item?.status === 'number' ? item.status : null,
|
||||||
|
accessible: Boolean(item?.accessible),
|
||||||
|
posterPath: item?.posterPath ?? null,
|
||||||
|
backdropPath: item?.backdropPath ?? null,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
setIssueMediaResults(results.slice(0, 12))
|
||||||
|
if (results.length === 0) {
|
||||||
|
setError('No matching movie or TV show was found. Try the title without a year.')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Magent could not search the media catalogue.')
|
||||||
|
} finally {
|
||||||
|
setIssueMediaSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectIssueMedia = (media: DiscoveryResult) => {
|
||||||
|
setIssueSelectedMedia(media)
|
||||||
|
setIssueMediaTitle(media.title)
|
||||||
|
setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie')
|
||||||
|
setIssueMediaResults([])
|
||||||
|
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`)
|
||||||
|
setError(null)
|
||||||
|
if (issueSupportsReplacement) {
|
||||||
|
void loadReplacementFiles(media)
|
||||||
|
} else {
|
||||||
|
setReplacementFiles([])
|
||||||
|
setSelectedReplacementFileId(null)
|
||||||
|
setStartReplacement(false)
|
||||||
|
setReplacementFilesMessage(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const checkMediaServer = async () => {
|
const checkMediaServer = async () => {
|
||||||
setMediaServerChecking(true)
|
setMediaServerChecking(true)
|
||||||
setMediaServerError(null)
|
setMediaServerError(null)
|
||||||
@@ -628,6 +792,21 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
setMediaServerError(null)
|
setMediaServerError(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
|
const supportsReplacement = ['broken_media', 'audio', 'subtitle', 'playback'].includes(category)
|
||||||
|
if (category === 'service_unavailable') {
|
||||||
|
setIssueSelectedMedia(null)
|
||||||
|
setIssueMediaTitle('')
|
||||||
|
setIssueMediaQuery('')
|
||||||
|
setIssueMediaResults([])
|
||||||
|
}
|
||||||
|
if (supportsReplacement && issueSelectedMedia?.requestId) {
|
||||||
|
void loadReplacementFiles(issueSelectedMedia)
|
||||||
|
} else if (!supportsReplacement) {
|
||||||
|
setReplacementFiles([])
|
||||||
|
setSelectedReplacementFileId(null)
|
||||||
|
setStartReplacement(false)
|
||||||
|
setReplacementFilesMessage(null)
|
||||||
|
}
|
||||||
if (category === 'playback' || category === 'service_unavailable') {
|
if (category === 'playback' || category === 'service_unavailable') {
|
||||||
void checkMediaServer()
|
void checkMediaServer()
|
||||||
}
|
}
|
||||||
@@ -640,8 +819,27 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cleanMediaTitle = issueMediaTitle.trim()
|
const cleanMediaTitle = issueMediaTitle.trim()
|
||||||
if (issueNeedsMediaTitle && !cleanMediaTitle) {
|
if (issueNeedsMediaTitle && (!cleanMediaTitle || !issueSelectedMedia)) {
|
||||||
setError('Enter the affected movie or TV show so the file can be identified.')
|
setError('Search for and select the exact movie or TV show so the managed file can be identified.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (startReplacement && (!issueSelectedMedia?.requestId || !selectedReplacementFile)) {
|
||||||
|
setError('Select the exact Sonarr/Radarr file before starting a replacement.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (startReplacement && !replacementAllowed) {
|
||||||
|
setError('Automatic replacement is disabled for this account. The issue can still be submitted.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
startReplacement &&
|
||||||
|
selectedReplacementFile &&
|
||||||
|
!window.confirm(
|
||||||
|
`Replace “${selectedReplacementFile.name}”?\n\n` +
|
||||||
|
'The current managed file will be removed and Sonarr/Radarr will immediately search for a replacement. ' +
|
||||||
|
'The title may be unavailable until the new download is imported.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +881,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
`Scope: ${scopeLabel}`,
|
`Scope: ${scopeLabel}`,
|
||||||
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
|
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
|
||||||
cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null,
|
cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null,
|
||||||
|
issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null,
|
||||||
|
selectedReplacementFile ? `Managed file: ${selectedReplacementFile.name}` : null,
|
||||||
|
startReplacement ? 'Replacement action: confirmed by the reporting user' : null,
|
||||||
issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null,
|
issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null,
|
||||||
issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null,
|
issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null,
|
||||||
...diagnosticLines,
|
...diagnosticLines,
|
||||||
@@ -709,6 +910,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
description,
|
description,
|
||||||
issue_type: resolvedIssueType,
|
issue_type: resolvedIssueType,
|
||||||
media_type: cleanMediaTitle ? issueMediaType : null,
|
media_type: cleanMediaTitle ? issueMediaType : null,
|
||||||
|
external_ref: issueSelectedMedia?.requestId
|
||||||
|
? `/requests/${issueSelectedMedia.requestId}`
|
||||||
|
: null,
|
||||||
priority,
|
priority,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -723,11 +927,39 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const item = data?.item as PortalItem | undefined
|
const item = data?.item as PortalItem | undefined
|
||||||
setStatus(
|
let completionMessage = item?.id
|
||||||
item?.id
|
? `Issue #${item.id} submitted with the troubleshooting details.`
|
||||||
? `Issue #${item.id} submitted with the troubleshooting details.`
|
: 'Issue submitted with the troubleshooting details.'
|
||||||
: 'Issue submitted with the troubleshooting details.'
|
let replacementFailure: string | null = null
|
||||||
)
|
if (startReplacement && issueSelectedMedia?.requestId && selectedReplacementFile) {
|
||||||
|
const replacementResponse = await authFetch(
|
||||||
|
`${getApiBase()}/requests/${issueSelectedMedia.requestId}/actions/replace`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_id: selectedReplacementFile.id,
|
||||||
|
confirmed: true,
|
||||||
|
issue_id: item?.id ?? null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (replacementResponse.ok) {
|
||||||
|
const replacementPayload = await replacementResponse.json()
|
||||||
|
completionMessage = `${completionMessage} ${replacementPayload?.message ?? 'Replacement search started.'}`
|
||||||
|
} else {
|
||||||
|
let replacementDetail = 'The replacement action could not be started.'
|
||||||
|
try {
|
||||||
|
const replacementPayload = await replacementResponse.json()
|
||||||
|
if (typeof replacementPayload?.detail === 'string') replacementDetail = replacementPayload.detail
|
||||||
|
} catch {
|
||||||
|
// Keep the user-safe fallback message.
|
||||||
|
}
|
||||||
|
replacementFailure = `The issue was saved, but ${replacementDetail}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStatus(completionMessage)
|
||||||
|
setError(replacementFailure)
|
||||||
setIssueCategory(null)
|
setIssueCategory(null)
|
||||||
setIssueMediaTitle('')
|
setIssueMediaTitle('')
|
||||||
setIssueMediaType('movie')
|
setIssueMediaType('movie')
|
||||||
@@ -737,6 +969,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
setIssueDevice('')
|
setIssueDevice('')
|
||||||
setIssueNotes('')
|
setIssueNotes('')
|
||||||
setMediaServerStatus(null)
|
setMediaServerStatus(null)
|
||||||
|
setIssueMediaQuery('')
|
||||||
|
setIssueMediaResults([])
|
||||||
|
setIssueSelectedMedia(null)
|
||||||
|
setReplacementFiles([])
|
||||||
|
setReplacementFilesMessage(null)
|
||||||
|
setReplacementFileFilter('')
|
||||||
|
setSelectedReplacementFileId(null)
|
||||||
|
setStartReplacement(false)
|
||||||
|
setReplacementAllowed(true)
|
||||||
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()])
|
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -1123,7 +1364,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
className={issueScope === value ? 'is-selected' : ''}
|
className={issueScope === value ? 'is-selected' : ''}
|
||||||
onClick={() => setIssueScope(value as typeof issueScope)}
|
onClick={() => {
|
||||||
|
const nextScope = value as typeof issueScope
|
||||||
|
setIssueScope(nextScope)
|
||||||
|
if (nextScope !== 'one_title') {
|
||||||
|
setStartReplacement(false)
|
||||||
|
setSelectedReplacementFileId(null)
|
||||||
|
} else if (issueSelectedMedia?.requestId) {
|
||||||
|
void loadReplacementFiles(issueSelectedMedia)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@@ -1134,27 +1384,137 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
|
|
||||||
<div className="issue-question-grid">
|
<div className="issue-question-grid">
|
||||||
{issueNeedsMediaTitle ? (
|
{issueNeedsMediaTitle ? (
|
||||||
<label className="issue-field-span-2">
|
<div className="issue-media-finder issue-field-span-2">
|
||||||
<span>Affected movie or TV show</span>
|
<label>
|
||||||
<input
|
<span>Find the exact movie or TV show</span>
|
||||||
required
|
<div className="issue-media-search-row">
|
||||||
value={issueMediaTitle}
|
<input
|
||||||
onChange={(event) => setIssueMediaTitle(event.target.value)}
|
value={issueMediaQuery}
|
||||||
placeholder="Start typing the exact title"
|
onChange={(event) => {
|
||||||
/>
|
setIssueMediaQuery(event.target.value)
|
||||||
</label>
|
if (issueSelectedMedia) {
|
||||||
) : null}
|
setIssueSelectedMedia(null)
|
||||||
{issueNeedsMediaTitle ? (
|
setIssueMediaTitle('')
|
||||||
<label>
|
setReplacementFiles([])
|
||||||
<span>Media type</span>
|
setSelectedReplacementFileId(null)
|
||||||
<select
|
setStartReplacement(false)
|
||||||
value={issueMediaType}
|
}
|
||||||
onChange={(event) => setIssueMediaType(event.target.value as 'movie' | 'tv')}
|
}}
|
||||||
>
|
placeholder="Search the Grizzlyflix catalogue"
|
||||||
<option value="movie">Movie</option>
|
onKeyDown={(event) => {
|
||||||
<option value="tv">TV show</option>
|
if (event.key === 'Enter') {
|
||||||
</select>
|
event.preventDefault()
|
||||||
</label>
|
void searchIssueMedia()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => void searchIssueMedia()} disabled={issueMediaSearching}>
|
||||||
|
{issueMediaSearching ? 'Searching...' : 'Search'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{issueMediaResults.length > 0 ? (
|
||||||
|
<div className="issue-media-results">
|
||||||
|
{issueMediaResults.map((media, index) => {
|
||||||
|
const poster = resolveTmdbArtworkUrl(media.posterPath, 'w185')
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${media.type}:${media.tmdbId ?? index}`}
|
||||||
|
type="button"
|
||||||
|
className="issue-media-result"
|
||||||
|
onClick={() => selectIssueMedia(media)}
|
||||||
|
>
|
||||||
|
<span className="issue-media-poster">
|
||||||
|
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<small>{media.type === 'tv' ? 'TV show' : 'Movie'}{media.year ? ` · ${media.year}` : ''}</small>
|
||||||
|
<strong>{media.title}</strong>
|
||||||
|
<b>{media.requestId ? media.statusLabel || `Request #${media.requestId}` : 'Not currently requested'}</b>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{issueSelectedMedia ? (
|
||||||
|
<div className="issue-selected-media">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Selected title</span>
|
||||||
|
<strong>{issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''}</strong>
|
||||||
|
<small>
|
||||||
|
{issueSelectedMedia.type === 'tv' ? 'TV show' : 'Movie'}
|
||||||
|
{issueSelectedMedia.requestId
|
||||||
|
? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? 'Tracked'}`
|
||||||
|
: ' · No existing Magent request'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{issueSelectedMedia.requestId ? (
|
||||||
|
<button type="button" className="ghost-button" onClick={() => router.push(`/requests/${issueSelectedMedia.requestId}`)}>
|
||||||
|
Open request
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{issueSelectedMedia && issueSupportsReplacement ? (
|
||||||
|
<div className="issue-file-picker">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Sonarr / Radarr files</span>
|
||||||
|
<h3>Select the exact file that is faulty</h3>
|
||||||
|
<p>The selected file is validated again before Magent allows replacement.</p>
|
||||||
|
</div>
|
||||||
|
{replacementFilesLoading ? (
|
||||||
|
<div className="issue-live-scan"><i /><span>Reading managed files from the collector</span></div>
|
||||||
|
) : null}
|
||||||
|
{replacementFilesMessage ? <div className="status-banner">{replacementFilesMessage}</div> : null}
|
||||||
|
{replacementFiles.length > 6 ? (
|
||||||
|
<input
|
||||||
|
value={replacementFileFilter}
|
||||||
|
onChange={(event) => setReplacementFileFilter(event.target.value)}
|
||||||
|
placeholder="Filter by season, episode, filename, or quality"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{visibleReplacementFiles.length > 0 ? (
|
||||||
|
<div className="issue-file-list">
|
||||||
|
{visibleReplacementFiles.map((file) => (
|
||||||
|
<label key={file.id} className={selectedReplacementFileId === file.id ? 'is-selected' : ''}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="replacement-file"
|
||||||
|
checked={selectedReplacementFileId === file.id}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedReplacementFileId(file.id)
|
||||||
|
setStartReplacement(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{file.episodes?.length ? file.episodes.join(', ') : issueSelectedMedia.title}</strong>
|
||||||
|
<small>{file.name}</small>
|
||||||
|
</span>
|
||||||
|
<b>{[file.quality, formatFileSize(file.size)].filter(Boolean).join(' · ')}</b>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedReplacementFile && replacementAllowed ? (
|
||||||
|
<label className="issue-replacement-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={startReplacement}
|
||||||
|
onChange={(event) => setStartReplacement(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Replace this file after submitting the issue</strong>
|
||||||
|
<small>
|
||||||
|
Removes only this managed file, then immediately starts a replacement search through {issueMediaType === 'tv' ? 'Sonarr' : 'Radarr'}.
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{issueNeedsMediaTitle && issueMediaType === 'tv' ? (
|
{issueNeedsMediaTitle && issueMediaType === 'tv' ? (
|
||||||
<label>
|
<label>
|
||||||
@@ -1232,13 +1592,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<span className="section-kicker">Recommended path</span>
|
<span className="section-kicker">Recommended path</span>
|
||||||
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2>
|
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2>
|
||||||
<p>
|
<p>
|
||||||
{issueNeedsServerCheck
|
{startReplacement && selectedReplacementFile
|
||||||
|
? `After confirmation, ${selectedReplacementFile.name} will be removed and the collector will start searching immediately.`
|
||||||
|
: issueNeedsServerCheck
|
||||||
? 'The live check above will be saved in the report, giving administrators immediate context.'
|
? 'The live check above will be saved in the report, giving administrators immediate context.'
|
||||||
: 'Submit this report and it will arrive with the replacement or collection path already identified.'}
|
: 'Submit this report and it will arrive with the replacement or collection path already identified.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={creating || mediaServerChecking}>
|
<button type="submit" disabled={creating || mediaServerChecking}>
|
||||||
{creating ? 'Submitting...' : 'Submit issue'}
|
{creating ? 'Working...' : startReplacement ? 'Submit issue & replace file' : 'Submit issue'}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
@@ -1502,6 +1864,18 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (
|
||||||
|
<div className="issue-linked-request">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Linked collection record</span>
|
||||||
|
<strong>{selectedItem.external_ref.replace('/requests/', 'Request #')}</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => router.push(selectedItem.external_ref as string)}>
|
||||||
|
Open request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
||||||
<label className="portal-field-span-2">
|
<label className="portal-field-span-2">
|
||||||
<span>Title</span>
|
<span>Title</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user