diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 6a6f24c..81ad517 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -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) diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py index b64d196..141b0d8 100644 --- a/backend/app/clients/radarr.py +++ b/backend/app/clients/radarr.py @@ -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, diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index 5686ba2..5f5a6d9 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -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, diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 721efa2..0cad4b8 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -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() diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index f96fc82..ec06348 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -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) diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index d5c104d..d748175 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -2743,6 +2743,204 @@ button:disabled, 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 { display: grid; gap: 13px; @@ -2859,6 +3057,22 @@ button:disabled, 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 { display: -webkit-box; overflow: hidden; @@ -2880,9 +3094,15 @@ button:disabled, .issue-flow { padding: 14px; } .issue-category-grid, .issue-question-grid, + .issue-media-results, .media-status-metrics { grid-template-columns: 1fr; } .issue-field-span-2 { grid-column: span 1; } .issue-category-card { min-height: 0; } .issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); } .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; } } diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx index b469b23..1f880da 100644 --- a/frontend/app/portal/PortalClient.tsx +++ b/frontend/app/portal/PortalClient.tsx @@ -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<{ id: IssueCategoryId marker: string @@ -265,6 +274,13 @@ const formatDate = (value?: string | null) => { 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 parsed = Number.parseInt(value, 10) if (Number.isNaN(parsed) || parsed <= 0) return null @@ -338,6 +354,17 @@ export default function PortalClient({ workspace }: PortalClientProps) { const [mediaServerStatus, setMediaServerStatus] = useState(null) const [mediaServerChecking, setMediaServerChecking] = useState(false) const [mediaServerError, setMediaServerError] = useState(null) + const [issueMediaQuery, setIssueMediaQuery] = useState('') + const [issueMediaSearching, setIssueMediaSearching] = useState(false) + const [issueMediaResults, setIssueMediaResults] = useState([]) + const [issueSelectedMedia, setIssueSelectedMedia] = useState(null) + const [replacementFiles, setReplacementFiles] = useState([]) + const [replacementFilesLoading, setReplacementFilesLoading] = useState(false) + const [replacementFilesMessage, setReplacementFilesMessage] = useState(null) + const [replacementFileFilter, setReplacementFileFilter] = useState('') + const [selectedReplacementFileId, setSelectedReplacementFileId] = useState(null) + const [startReplacement, setStartReplacement] = useState(false) + const [replacementAllowed, setReplacementAllowed] = useState(true) const isAdmin = me?.role === 'admin' const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0) @@ -348,7 +375,21 @@ export default function PortalClient({ workspace }: PortalClientProps) { const issueNeedsMediaTitle = Boolean(issueCategory) && 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(() => { 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 () => { setMediaServerChecking(true) setMediaServerError(null) @@ -628,6 +792,21 @@ export default function PortalClient({ workspace }: PortalClientProps) { setMediaServerError(null) setError(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') { void checkMediaServer() } @@ -640,8 +819,27 @@ export default function PortalClient({ workspace }: PortalClientProps) { return } const cleanMediaTitle = issueMediaTitle.trim() - if (issueNeedsMediaTitle && !cleanMediaTitle) { - setError('Enter the affected movie or TV show so the file can be identified.') + if (issueNeedsMediaTitle && (!cleanMediaTitle || !issueSelectedMedia)) { + 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 } @@ -683,6 +881,9 @@ export default function PortalClient({ workspace }: PortalClientProps) { `Scope: ${scopeLabel}`, cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : 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, issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null, ...diagnosticLines, @@ -709,6 +910,9 @@ export default function PortalClient({ workspace }: PortalClientProps) { description, issue_type: resolvedIssueType, media_type: cleanMediaTitle ? issueMediaType : null, + external_ref: issueSelectedMedia?.requestId + ? `/requests/${issueSelectedMedia.requestId}` + : null, priority, }), }) @@ -723,11 +927,39 @@ export default function PortalClient({ workspace }: PortalClientProps) { } const data = await response.json() const item = data?.item as PortalItem | undefined - setStatus( - item?.id - ? `Issue #${item.id} submitted with the troubleshooting details.` - : 'Issue submitted with the troubleshooting details.' - ) + let completionMessage = item?.id + ? `Issue #${item.id} 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) setIssueMediaTitle('') setIssueMediaType('movie') @@ -737,6 +969,15 @@ export default function PortalClient({ workspace }: PortalClientProps) { setIssueDevice('') setIssueNotes('') 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()]) } catch (err) { console.error(err) @@ -1123,7 +1364,16 @@ export default function PortalClient({ workspace }: PortalClientProps) { key={value} type="button" 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} @@ -1134,27 +1384,137 @@ export default function PortalClient({ workspace }: PortalClientProps) {
{issueNeedsMediaTitle ? ( - - ) : null} - {issueNeedsMediaTitle ? ( - +
+ + {issueMediaResults.length > 0 ? ( +
+ {issueMediaResults.map((media, index) => { + const poster = resolveTmdbArtworkUrl(media.posterPath, 'w185') + return ( + + ) + })} +
+ ) : null} + {issueSelectedMedia ? ( +
+
+ Selected title + {issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''} + + {issueSelectedMedia.type === 'tv' ? 'TV show' : 'Movie'} + {issueSelectedMedia.requestId + ? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? 'Tracked'}` + : ' · No existing Magent request'} + +
+ {issueSelectedMedia.requestId ? ( + + ) : null} +
+ ) : null} + + {issueSelectedMedia && issueSupportsReplacement ? ( +
+
+ Sonarr / Radarr files +

Select the exact file that is faulty

+

The selected file is validated again before Magent allows replacement.

+
+ {replacementFilesLoading ? ( +
Reading managed files from the collector
+ ) : null} + {replacementFilesMessage ?
{replacementFilesMessage}
: null} + {replacementFiles.length > 6 ? ( + setReplacementFileFilter(event.target.value)} + placeholder="Filter by season, episode, filename, or quality" + /> + ) : null} + {visibleReplacementFiles.length > 0 ? ( +
+ {visibleReplacementFiles.map((file) => ( + + ))} +
+ ) : null} + {selectedReplacementFile && replacementAllowed ? ( + + ) : null} +
+ ) : null} +
) : null} {issueNeedsMediaTitle && issueMediaType === 'tv' ? (
@@ -1502,6 +1864,18 @@ export default function PortalClient({ workspace }: PortalClientProps) { + {selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? ( +
+
+ Linked collection record + {selectedItem.external_ref.replace('/requests/', 'Request #')} +
+ +
+ ) : null} +