From e58614305e481c65461867ab31ba53e0b2779766 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Tue, 1 Sep 2026 13:44:13 +1200 Subject: [PATCH] Streamline issue reporting and automate repairs --- backend/app/clients/base.py | 8 + backend/app/clients/bazarr.py | 48 ++ backend/app/config.py | 10 + backend/app/routers/admin.py | 5 + backend/app/routers/requests.py | 389 +++++++++++++- backend/app/routers/status.py | 15 + backend/tests/test_backend_quality.py | 163 ++++++ frontend/app/admin/SettingsPage.tsx | 26 +- frontend/app/admin/[section]/page.tsx | 1 + frontend/app/ops-redesign.css | 179 +++++++ frontend/app/portal/PortalClient.tsx | 711 +++++++++++++++----------- frontend/app/ui/AdminSidebar.tsx | 1 + 12 files changed, 1223 insertions(+), 333 deletions(-) create mode 100644 backend/app/clients/bazarr.py diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index 1a234e4..de032ad 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -11,6 +11,7 @@ _SERVICE_NAMES = { "JellyseerrClient": "Seerr", "SonarrClient": "Sonarr", "RadarrClient": "Radarr", + "BazarrClient": "Bazarr", "ProwlarrClient": "Prowlarr", "JellyfinClient": "Jellyfin", "QBittorrentClient": "qBittorrent", @@ -184,6 +185,11 @@ def _operation_result_message( else "Prowlarr did not find any possible releases." ) + if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path: + target = "movie" if "/movies/" in normalized_path else "selected episode" + language = str((params or {}).get("language") or "the requested language").upper() + return f"Bazarr accepted a fresh {language} subtitle search for the {target}." + if normalized_method == "GET": return f"{service} completed the check successfully." if normalized_method == "POST": @@ -234,6 +240,8 @@ def _operation_messages(service: str, method: str, path: str) -> tuple[str, str] return f"Checking releases through {service}…", f"{service} returned release information" if service in {"Radarr", "Sonarr"} and "/command" in normalized_path: return f"Sending a command to {service}…", f"{service} accepted the command" + if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH": + return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search" if service == "Prowlarr" and "/health" in normalized_path: return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health" return f"Contacting {service}…", f"{service} responded" diff --git a/backend/app/clients/bazarr.py b/backend/app/clients/bazarr.py new file mode 100644 index 0000000..703ff10 --- /dev/null +++ b/backend/app/clients/bazarr.py @@ -0,0 +1,48 @@ +from typing import Any, Optional + +from .base import ApiClient + + +class BazarrClient(ApiClient): + async def get_system_status(self) -> Optional[Any]: + return await self._request("GET", "/api/system/status") + + async def search_movie_subtitles( + self, + radarr_id: int, + *, + language: str, + forced: bool = False, + ) -> Optional[Any]: + return await self._request( + "PATCH", + "/api/movies/subtitles", + params={ + "radarrid": radarr_id, + "language": language, + "forced": str(forced).lower(), + "hi": "false", + }, + timeout_seconds=90.0, + ) + + async def search_episode_subtitles( + self, + series_id: int, + episode_id: int, + *, + language: str, + forced: bool = False, + ) -> Optional[Any]: + return await self._request( + "PATCH", + "/api/episodes/subtitles", + params={ + "seriesid": series_id, + "episodeid": episode_id, + "language": language, + "forced": str(forced).lower(), + "hi": "false", + }, + timeout_seconds=90.0, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 1fcfa97..c46407f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -305,6 +305,16 @@ class Settings(BaseSettings): validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"), ) + bazarr_base_url: Optional[str] = Field( + default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL") + ) + bazarr_api_key: Optional[str] = Field( + default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY") + ) + bazarr_default_language: str = Field( + default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE") + ) + prowlarr_base_url: Optional[str] = Field( default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL") ) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 5f1a8ee..4b4efdd 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -135,6 +135,7 @@ SENSITIVE_KEYS = { "jellyfin_api_key", "sonarr_api_key", "radarr_api_key", + "bazarr_api_key", "prowlarr_api_key", "qbittorrent_password", } @@ -150,6 +151,7 @@ URL_SETTING_KEYS = { "jellyfin_public_url", "sonarr_base_url", "radarr_base_url", + "bazarr_base_url", "prowlarr_base_url", "qbittorrent_base_url", } @@ -216,6 +218,9 @@ SETTING_KEYS: List[str] = [ "radarr_quality_profile_id", "radarr_root_folder", "radarr_qbittorrent_category", + "bazarr_base_url", + "bazarr_api_key", + "bazarr_default_language", "prowlarr_base_url", "prowlarr_api_key", "qbittorrent_base_url", diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index e672855..6cb3542 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -14,6 +14,7 @@ from ..clients.jellyfin import JellyfinClient from ..clients.qbittorrent import QBittorrentClient from ..clients.radarr import RadarrClient from ..clients.sonarr import SonarrClient +from ..clients.bazarr import BazarrClient from ..ai.triage import triage_snapshot from ..auth import get_current_user from ..runtime import get_runtime_settings @@ -1838,6 +1839,77 @@ def _record_replacement_activity( ) +def _released_episode(episode: Dict[str, Any]) -> bool: + if episode.get("hasFile") is True: + return True + raw_date = episode.get("airDateUtc") or episode.get("airDate") + if not isinstance(raw_date, str) or not raw_date.strip(): + return False + try: + parsed = datetime.fromisoformat(raw_date.strip().replace("Z", "+00:00")) + except ValueError: + return False + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) <= datetime.now(timezone.utc) + + +def _issue_episode_payloads( + episodes: Any, +) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + if not isinstance(episodes, list): + return results + for episode in episodes: + if not isinstance(episode, dict): + continue + episode_id = episode.get("id") + season_number = episode.get("seasonNumber") + episode_number = episode.get("episodeNumber") + if not all(isinstance(value, int) for value in (episode_id, season_number, episode_number)): + continue + released = _released_episode(episode) + has_file = episode.get("hasFile") is True or ( + isinstance(episode.get("episodeFileId"), int) and episode.get("episodeFileId") > 0 + ) + monitored = episode.get("monitored") is not False + results.append( + { + "id": episode_id, + "season_number": season_number, + "episode_number": episode_number, + "code": f"S{season_number:02d}E{episode_number:02d}", + "title": str(episode.get("title") or f"Episode {episode_number}").strip(), + "released": released, + "monitored": monitored, + "has_file": has_file, + "missing": released and monitored and not has_file, + "best_fit": released and monitored and not has_file, + "file_id": episode.get("episodeFileId") if has_file else None, + } + ) + results.sort(key=lambda item: (item["season_number"], item["episode_number"])) + return results + + +def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + grouped: Dict[int, List[Dict[str, Any]]] = {} + for episode in episodes: + grouped.setdefault(int(episode["season_number"]), []).append(episode) + return [ + { + "season_number": season_number, + "label": "Specials" if season_number == 0 else f"Season {season_number}", + "episode_count": len(items), + "available_count": sum(1 for item in items if item["has_file"]), + "missing_count": sum(1 for item in items if item["missing"]), + "best_fit": any(item["best_fit"] for item in items), + } + for season_number, items in sorted(grouped.items()) + if any(item["released"] for item in items) + ] + + 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() @@ -1851,6 +1923,80 @@ async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: return root_folder +@router.get("/{request_id}/issue-options") +async def issue_target_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, + "collector_id": None, + "movie": None, + "seasons": [], + "episodes": [], + "can_act": False, + "message": "This title is not currently linked to Sonarr or Radarr.", + } + + collector_id = arr_item.get("id") + if not isinstance(collector_id, int): + raise HTTPException(status_code=502, detail="Sonarr/Radarr returned an invalid media record") + if snapshot.request_type == RequestType.movie: + movie_file = arr_item.get("movieFile") if isinstance(arr_item.get("movieFile"), dict) else None + return { + "request_id": request_id, + "request_type": "movie", + "title": snapshot.title, + "collector_id": collector_id, + "movie": { + "selected_label": snapshot.title, + "has_file": bool(movie_file), + "missing": not bool(movie_file), + "best_fit": not bool(movie_file), + "file_id": movie_file.get("id") if movie_file else None, + }, + "seasons": [], + "episodes": [], + "can_act": _user_can_use_search_auto(user), + "message": "Choose the movie to continue.", + } + + 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: + episodes = await sonarr.get_episodes(collector_id) + except Exception as exc: + logger.warning("Sonarr issue options failed request_id=%s error=%s", request_id, exc) + raise HTTPException( + status_code=502, + detail="Magent could not read the seasons and episodes from Sonarr.", + ) from exc + episode_options = _issue_episode_payloads(episodes) + return { + "request_id": request_id, + "request_type": "tv", + "title": snapshot.title, + "collector_id": collector_id, + "movie": None, + "seasons": _issue_season_payloads(episode_options), + "episodes": episode_options, + "can_act": _user_can_use_search_auto(user), + "message": "Choose a season, then select every affected episode.", + } + + @router.get("/{request_id}/replacement-options") async def replacement_options( request_id: str, @@ -1954,9 +2100,18 @@ async def action_replace_media( 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") + raw_file_ids = payload.get("file_ids") + if raw_file_ids is None: + raw_file_ids = [payload.get("file_id")] + if not isinstance(raw_file_ids, list): + raise HTTPException(status_code=400, detail="Managed files must be supplied as a list") + file_ids = list(dict.fromkeys( + value + for value in raw_file_ids + if isinstance(value, int) and not isinstance(value, bool) and value > 0 + )) + if not file_ids or len(file_ids) != len(raw_file_ids) or len(file_ids) > 100: + raise HTTPException(status_code=400, detail="Choose between 1 and 100 valid managed files") linked_issue = _linked_issue_for_replacement( payload.get("issue_id"), request_id=request_id, @@ -1973,20 +2128,20 @@ async def action_replace_media( 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" + target_names: List[str] = [] 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: + if len(file_ids) != 1 or movie_file.get("id") != file_ids[0]: raise HTTPException(status_code=409, detail="The selected movie file is no longer current") - target_name = _replacement_file_name(movie_file) + target_names = [_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.delete_movie_file(file_ids[0]) await radarr.search(movie_id) elif snapshot.request_type == RequestType.tv: series_id = arr_item.get("id") @@ -1999,27 +2154,25 @@ async def action_replace_media( 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") + selected_files = [ + item + for item in episode_files + if isinstance(item, dict) and item.get("id") in file_ids + ] if isinstance(episode_files, list) else [] + if len(selected_files) != len(file_ids): + raise HTTPException(status_code=409, detail="One or more selected episode files are no longer current") episode_ids = [ episode.get("id") for episode in episodes if isinstance(episode, dict) - and episode.get("episodeFileId") == file_id + and episode.get("episodeFileId") in file_ids 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) + target_names = [_replacement_file_name(file_data) for file_data in selected_files] + for selected_file_id in file_ids: + await sonarr.delete_episode_file(selected_file_id) await sonarr.search_episodes(episode_ids) else: raise HTTPException(status_code=400, detail="Unknown request type") @@ -2032,7 +2185,7 @@ async def action_replace_media( ) raise except Exception as exc: - logger.exception("%s media replacement failed request_id=%s file_id=%s", collector, request_id, file_id) + logger.exception("%s media replacement failed request_id=%s file_ids=%s", collector, request_id, file_ids) detail = ( f"{collector} could not complete the replacement. Check the request action history " "before trying again." @@ -2053,7 +2206,8 @@ async def action_replace_media( ) raise HTTPException(status_code=502, detail=detail) from exc - message = f"{collector} removed {target_name} and started a replacement search." + file_label = "the selected managed file" if len(target_names) == 1 else f"{len(target_names)} selected managed files" + message = f"{collector} removed {file_label} and started a replacement search." await asyncio.to_thread( save_action, request_id, @@ -2073,10 +2227,199 @@ async def action_replace_media( "message": message, "collector": collector, "request_id": request_id, - "file_id": file_id, + "file_ids": file_ids, } +def _positive_id_list( + value: Any, + *, + field: str, + maximum: int = 200, + minimum: int = 1, +) -> List[int]: + if value is None: + return [] + if not isinstance(value, list): + raise HTTPException(status_code=400, detail=f"{field} must be a list") + normalized = list( + dict.fromkeys( + item + for item in value + if isinstance(item, int) and not isinstance(item, bool) and item >= minimum + ) + ) + if len(normalized) != len(value) or len(normalized) > maximum: + raise HTTPException(status_code=400, detail=f"Choose up to {maximum} valid {field.replace('_', ' ')}") + return normalized + + +@router.post("/{request_id}/actions/search-missing") +async def action_search_missing_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="Collection searches are disabled for this user") + linked_issue = _linked_issue_for_replacement( + payload.get("issue_id"), request_id=request_id, user=user + ) + episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids") + season_numbers = _positive_id_list( + payload.get("season_numbers"), field="season_numbers", maximum=100, minimum=0 + ) + runtime = get_runtime_settings() + snapshot = await build_snapshot(request_id) + arr_item = snapshot.raw.get("arr", {}).get("item") + if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): + raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr") + collector_id = int(arr_item["id"]) + try: + if snapshot.request_type == RequestType.movie: + 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.search(collector_id) + message = "Radarr started searching for the missing movie." + searched_ids: List[int] = [] + else: + sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) + if not sonarr.configured(): + raise HTTPException(status_code=400, detail="Sonarr is not configured") + episodes = await sonarr.get_episodes(collector_id) + if not isinstance(episodes, list): + raise HTTPException(status_code=502, detail="Sonarr did not return an episode list") + episode_map = { + int(item["id"]): item + for item in episodes + if isinstance(item, dict) and isinstance(item.get("id"), int) + } + if episode_ids: + if any(item_id not in episode_map for item_id in episode_ids): + raise HTTPException(status_code=409, detail="One or more selected episodes are no longer in Sonarr") + searched_ids = episode_ids + else: + searched_ids = [ + item_id + for item_id, episode in episode_map.items() + if _released_episode(episode) + and episode.get("monitored") is not False + and not ( + episode.get("hasFile") is True + or (isinstance(episode.get("episodeFileId"), int) and episode.get("episodeFileId") > 0) + ) + and (not season_numbers or episode.get("seasonNumber") in season_numbers) + ] + if searched_ids: + await sonarr.search_episodes(searched_ids) + message = f"Sonarr started searching for {len(searched_ids)} selected missing episode(s)." + else: + await sonarr.search(collector_id) + message = "Sonarr refreshed the series and started a full missing-episode search." + except HTTPException as exc: + _record_replacement_activity( + linked_issue, + user=user, + event_type="missing_search_failed", + message=f"The missing-content search could not start: {exc.detail}", + ) + raise + except Exception as exc: + logger.exception("missing content search failed request_id=%s", request_id) + detail = "Sonarr/Radarr could not start the missing-content search." + _record_replacement_activity( + linked_issue, user=user, event_type="missing_search_failed", message=detail + ) + raise HTTPException(status_code=502, detail=detail) from exc + await asyncio.to_thread( + save_action, request_id, "search_missing", "Search for missing content", "ok", message + ) + _record_replacement_activity( + linked_issue, user=user, event_type="missing_search_started", message=message + ) + return {"status": "ok", "message": message, "episode_ids": searched_ids} + + +@router.post("/{request_id}/actions/repair-subtitles") +async def action_repair_subtitles( + 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="Subtitle repairs are disabled for this user") + linked_issue = _linked_issue_for_replacement( + payload.get("issue_id"), request_id=request_id, user=user + ) + episode_ids = _positive_id_list(payload.get("episode_ids"), field="episode_ids", maximum=100) + forced = payload.get("forced") is True + runtime = get_runtime_settings() + bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key) + if not bazarr.configured() or not runtime.bazarr_api_key: + raise HTTPException(status_code=400, detail="Bazarr is not configured") + language = str(runtime.bazarr_default_language or "en").strip().lower() or "en" + snapshot = await build_snapshot(request_id) + arr_item = snapshot.raw.get("arr", {}).get("item") + if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int): + raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr") + collector_id = int(arr_item["id"]) + try: + if snapshot.request_type == RequestType.movie: + await bazarr.search_movie_subtitles( + collector_id, language=language, forced=forced + ) + repaired_count = 1 + message = f"Bazarr started a fresh {language.upper()} subtitle search for the movie." + else: + if not episode_ids: + raise HTTPException(status_code=400, detail="Choose at least one episode") + sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) + episodes = await sonarr.get_episodes(collector_id) + valid_ids = { + int(item["id"]) + for item in episodes + if isinstance(item, dict) and isinstance(item.get("id"), int) + } if isinstance(episodes, list) else set() + if any(episode_id not in valid_ids for episode_id in episode_ids): + raise HTTPException(status_code=409, detail="One or more selected episodes are no longer in Sonarr") + for episode_id in episode_ids: + await bazarr.search_episode_subtitles( + collector_id, + episode_id, + language=language, + forced=forced, + ) + repaired_count = len(episode_ids) + message = f"Bazarr started fresh {language.upper()} subtitle searches for {repaired_count} episode(s)." + except HTTPException as exc: + _record_replacement_activity( + linked_issue, + user=user, + event_type="subtitle_repair_failed", + message=f"The subtitle repair could not start: {exc.detail}", + ) + raise + except Exception as exc: + logger.exception("Bazarr subtitle repair failed request_id=%s", request_id) + detail = "Bazarr could not start the subtitle repair." + _record_replacement_activity( + linked_issue, user=user, event_type="subtitle_repair_failed", message=detail + ) + raise HTTPException(status_code=502, detail=detail) from exc + await asyncio.to_thread( + save_action, request_id, "repair_subtitles", "Repair subtitles", "ok", message + ) + _record_replacement_activity( + linked_issue, user=user, event_type="subtitle_repair_started", message=message + ) + return {"status": "ok", "message": message, "count": repaired_count} + + @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/app/routers/status.py b/backend/app/routers/status.py index f5d2721..0d65bff 100644 --- a/backend/app/routers/status.py +++ b/backend/app/routers/status.py @@ -7,6 +7,7 @@ from ..runtime import get_runtime_settings from ..clients.jellyseerr import JellyseerrClient from ..clients.sonarr import SonarrClient from ..clients.radarr import RadarrClient +from ..clients.bazarr import BazarrClient from ..clients.prowlarr import ProwlarrClient from ..clients.qbittorrent import QBittorrentClient from ..clients.jellyfin import JellyfinClient @@ -61,6 +62,7 @@ async def services_status() -> Dict[str, Any]: jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) + bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key) prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) qbittorrent = QBittorrentClient( runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password @@ -89,6 +91,13 @@ async def services_status() -> Dict[str, Any]: radarr.get_system_status, ) ) + services.append( + await _check( + "Bazarr", + bazarr.configured() and bool(runtime.bazarr_api_key), + bazarr.get_system_status, + ) + ) prowlarr_status = await _check( "Prowlarr", prowlarr.configured(), @@ -124,6 +133,7 @@ async def test_service(service: str) -> Dict[str, Any]: jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) + bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key) prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) qbittorrent = QBittorrentClient( runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password @@ -144,6 +154,11 @@ async def test_service(service: str) -> Dict[str, Any]: ), "sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status), "radarr": ("Radarr", radarr.configured(), radarr.get_system_status), + "bazarr": ( + "Bazarr", + bazarr.configured() and bool(runtime.bazarr_api_key), + bazarr.get_system_status, + ), "prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health), "jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info), } diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index bde257a..dac29cd 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -254,6 +254,20 @@ class OperationMessageTests(unittest.TestCase): "The title is not currently available in Jellyfin.", ) + def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None: + message = _operation_result_message( + "Bazarr", + "PATCH", + "/api/episodes/subtitles", + {"status": True}, + params={"language": "en", "episodeid": 42}, + ) + + self.assertEqual( + message, + "Bazarr accepted a fresh EN subtitle search for the selected episode.", + ) + def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None: self.assertEqual( _operation_error_message("Radarr", 500), @@ -1359,6 +1373,155 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(result["files"][0]["episodes"], ["S02E03"]) self.assertNotIn("/private/library", str(result)) + async def test_issue_options_mark_released_missing_episodes_as_best_fit(self) -> None: + snapshot = Snapshot( + request_id="3909", + title="Target Series", + request_type=RequestType.tv, + state=NormalizedState.downloading, + raw={"arr": {"item": {"id": 22}}}, + ) + sonarr = SimpleNamespace( + configured=lambda: True, + get_episodes=AsyncMock(return_value=[ + { + "id": 101, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Collected", + "monitored": True, + "hasFile": True, + "episodeFileId": 88, + "airDateUtc": "2020-01-01T00:00:00Z", + }, + { + "id": 102, + "seasonNumber": 1, + "episodeNumber": 2, + "title": "Missing", + "monitored": True, + "hasFile": False, + "episodeFileId": 0, + "airDateUtc": "2020-01-08T00:00:00Z", + }, + ]), + ) + runtime = SimpleNamespace( + jellyseerr_base_url=None, + jellyseerr_api_key=None, + 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.issue_target_options( + "3909", + {"username": "viewer", "role": "user", "auto_search_enabled": True}, + ) + + self.assertEqual(result["seasons"][0]["missing_count"], 1) + self.assertTrue(result["seasons"][0]["best_fit"]) + missing = next(item for item in result["episodes"] if item["id"] == 102) + self.assertTrue(missing["missing"]) + self.assertTrue(missing["best_fit"]) + collected = next(item for item in result["episodes"] if item["id"] == 101) + self.assertEqual(collected["file_id"], 88) + self.assertNotIn("file_name", collected) + self.assertNotIn("quality", collected) + + async def test_tv_replacement_accepts_multiple_selected_episode_files(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, "relativePath": "S01E01.mkv"}, + {"id": 89, "relativePath": "S01E02.mkv"}, + ]), + get_episodes=AsyncMock(return_value=[ + {"id": 101, "episodeFileId": 88}, + {"id": 102, "episodeFileId": 89}, + ]), + delete_episode_file=AsyncMock(return_value=None), + search_episodes=AsyncMock(return_value={"id": 1}), + ) + runtime = SimpleNamespace( + jellyseerr_base_url=None, + jellyseerr_api_key=None, + 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), + patch.object(requests_router, "save_action"), + patch.object(requests_router, "get_portal_item", return_value={ + "id": 12, + "kind": "issue", + "external_ref": "/requests/3909", + "created_by_username": "viewer", + }), + patch.object(requests_router, "add_portal_item_activity"), + ): + result = await requests_router.action_replace_media( + "3909", + {"file_ids": [88, 89], "confirmed": True, "issue_id": 12}, + {"username": "viewer", "role": "user", "auto_search_enabled": True}, + ) + + self.assertEqual(result["file_ids"], [88, 89]) + self.assertEqual(sonarr.delete_episode_file.await_count, 2) + sonarr.search_episodes.assert_awaited_once_with([101, 102]) + + async def test_movie_subtitle_issue_starts_bazarr_search_without_replacement(self) -> None: + snapshot = Snapshot( + request_id="3914", + title="Subtitle Movie", + request_type=RequestType.movie, + state=NormalizedState.available, + raw={"arr": {"item": {"id": 44, "movieFile": {"id": 77}}}}, + ) + bazarr = SimpleNamespace( + configured=lambda: True, + search_movie_subtitles=AsyncMock(return_value={"status": True}), + ) + runtime = SimpleNamespace( + bazarr_base_url="http://bazarr", + bazarr_api_key="secret", + bazarr_default_language="en", + ) + 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, "BazarrClient", return_value=bazarr), + patch.object(requests_router, "save_action"), + patch.object(requests_router, "get_portal_item", return_value={ + "id": 12, + "kind": "issue", + "external_ref": "/requests/3914", + "created_by_username": "viewer", + }), + patch.object(requests_router, "add_portal_item_activity") as add_activity, + ): + result = await requests_router.action_repair_subtitles( + "3914", + {"issue_id": 12, "episode_ids": [], "forced": True}, + {"username": "viewer", "role": "user", "auto_search_enabled": True}, + ) + + self.assertEqual(result["status"], "ok") + bazarr.search_movie_subtitles.assert_awaited_once_with(44, language="en", forced=True) + self.assertEqual(add_activity.call_args.kwargs["event_type"], "subtitle_repair_started") + class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 77644d2..b62bdb3 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -1,10 +1,10 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' -import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth' -import AdminShell from '../ui/AdminShell' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../lib/auth' import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel' +import AdminShell from '../ui/AdminShell' type AdminSetting = { key: string @@ -36,6 +36,7 @@ const SECTION_LABELS: Record = { cache: 'Request cache', sonarr: 'Sonarr', radarr: 'Radarr', + bazarr: 'Bazarr', prowlarr: 'Prowlarr', qbittorrent: 'qBittorrent', logs: 'Activity log', @@ -83,6 +84,7 @@ const URL_SETTINGS = new Set([ 'jellyfin_public_url', 'sonarr_base_url', 'radarr_base_url', + 'bazarr_base_url', 'prowlarr_base_url', 'qbittorrent_base_url', ]) @@ -115,6 +117,7 @@ const SECTION_DESCRIPTIONS: Record = { cache: 'Manage saved requests cache and refresh behavior.', sonarr: 'Sonarr connection and the default profile and library location for TV requests.', radarr: 'Radarr connection and the default profile and library location for movie requests.', + bazarr: 'Bazarr connection used to find and replace movie and episode subtitles.', prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.', qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.', requests: 'Control how often requests are refreshed and cleaned up.', @@ -134,6 +137,7 @@ const SETTINGS_SECTION_MAP: Record = { artwork: null, sonarr: 'sonarr', radarr: 'radarr', + bazarr: 'bazarr', prowlarr: 'prowlarr', qbittorrent: 'qbittorrent', requests: 'requests', @@ -361,6 +365,14 @@ const STANDARD_SECTION_GROUPS: Record< keys: ['radarr_quality_profile_id', 'radarr_root_folder'], }, ], + bazarr: [ + { + key: 'bazarr-connection', + title: 'Connection', + description: 'Bazarr endpoint, API credential, and default language used by subtitle issue repairs.', + keys: ['bazarr_base_url', 'bazarr_api_key', 'bazarr_default_language'], + }, + ], prowlarr: [ { key: 'prowlarr-connection', @@ -430,6 +442,9 @@ const STANDARD_SECTION_GROUPS: Record< } const SETTING_LABEL_OVERRIDES: Record = { + bazarr_base_url: 'Bazarr base URL', + bazarr_api_key: 'Bazarr API key', + bazarr_default_language: 'Default subtitle language', issue_confirmation_contact_attempts: 'Confirmation emails before auto-close', issue_confirmation_interval_value: 'Confirmation interval', issue_confirmation_interval_unit: 'Interval unit', @@ -993,6 +1008,9 @@ export default function SettingsPage({ section }: SettingsPageProps) { artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.', sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.', sonarr_api_key: 'API key for Sonarr.', + bazarr_base_url: 'Bazarr server URL used for movie and episode subtitle repairs. Scheme is optional.', + bazarr_api_key: 'API key used to ask Bazarr for fresh subtitles.', + bazarr_default_language: 'Language code Bazarr should search for by default, such as en.', sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', sonarr_root_folder: 'Root folder where Sonarr stores TV shows.', radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.', @@ -1067,6 +1085,8 @@ export default function SettingsPage({ section }: SettingsPageProps) { jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096', jellyfin_public_url: 'https://jelly.example.com', sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989', + bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767', + bazarr_default_language: 'en', radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878', prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696', qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080', diff --git a/frontend/app/admin/[section]/page.tsx b/frontend/app/admin/[section]/page.tsx index a99bf43..7285bd2 100644 --- a/frontend/app/admin/[section]/page.tsx +++ b/frontend/app/admin/[section]/page.tsx @@ -8,6 +8,7 @@ const ALLOWED_SECTIONS = new Set([ 'artwork', 'sonarr', 'radarr', + 'bazarr', 'prowlarr', 'qbittorrent', 'requests', diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 0f01061..e36a19d 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -2977,6 +2977,178 @@ button:disabled, gap: 3px; } +.issue-target-picker, +.issue-tv-targets { + display: grid; + gap: 12px; +} + +.issue-target-picker { + padding-top: 2px; +} + +.issue-choice-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.issue-choice-grid button, +.issue-season-grid button, +.issue-episode-grid button, +.issue-movie-target { + min-width: 0; + 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-choice-grid button { + display: flex; + align-items: center; + gap: 9px; + min-height: 52px; + padding: 10px 12px; +} + +.issue-choice-grid button > span { + display: grid; + flex: 0 0 auto; + place-items: center; + width: 23px; + height: 23px; + border: 1px solid rgba(126, 215, 255, 0.3); + border-radius: 50%; + color: var(--ops-cyan); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.66rem; +} + +.issue-choice-grid button strong, +.issue-season-grid button strong, +.issue-episode-grid button strong, +.issue-movie-target strong { + overflow-wrap: anywhere; + font-size: 0.76rem; + line-height: 1.35; + text-transform: none; +} + +.issue-choice-grid button.is-selected, +.issue-season-grid button.is-selected, +.issue-episode-grid button.is-selected, +.issue-movie-target.is-selected { + border-color: rgba(72, 224, 178, 0.55); + background: rgba(72, 224, 178, 0.08); + box-shadow: 0 0 20px rgba(72, 224, 178, 0.06); +} + +.issue-movie-target { + display: flex; + align-items: center; + gap: 11px; + width: 100%; + padding: 13px; +} + +.issue-movie-target > span { + flex: 0 0 auto; + color: var(--ops-cyan); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.65rem; +} + +.issue-movie-target > div, +.issue-season-grid button, +.issue-episode-grid button { + display: grid; + gap: 4px; +} + +.issue-movie-target small, +.issue-season-grid button small, +.issue-episode-grid button small { + color: var(--ops-muted); + font-size: 0.64rem; + font-weight: 600; + line-height: 1.4; + text-transform: none; +} + +.issue-movie-target:disabled, +.issue-episode-grid button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.issue-target-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + padding-top: 2px; +} + +.issue-target-heading strong { + font-size: 0.78rem; +} + +.issue-target-heading small { + color: var(--ops-muted); + font-size: 0.66rem; +} + +.issue-season-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.issue-season-grid button { + position: relative; + padding: 11px; +} + +.issue-season-grid button b, +.issue-episode-grid button b { + justify-self: start; + padding: 3px 6px; + border-radius: 999px; + background: rgba(72, 224, 178, 0.13); + color: var(--request-green); + font-size: 0.56rem; + line-height: 1.2; + text-transform: uppercase; +} + +.issue-season-tabs { + padding-top: 4px; + border-top: 1px solid var(--ops-line-soft); +} + +.issue-episode-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + max-height: 390px; + overflow: auto; + padding-right: 2px; +} + +.issue-episode-grid button { + align-content: start; + min-height: 91px; + padding: 11px; +} + +.issue-episode-grid button > span { + color: var(--ops-cyan); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.62rem; +} + .issue-file-picker { display: grid; gap: 11px; @@ -3212,6 +3384,9 @@ button:disabled, @media (max-width: 980px) { .issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .issue-choice-grid, + .issue-episode-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .issue-season-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .media-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -3222,6 +3397,9 @@ button:disabled, .issue-hero-count { width: 100%; } .issue-flow { padding: 14px; } .issue-category-grid, + .issue-choice-grid, + .issue-season-grid, + .issue-episode-grid, .issue-question-grid, .issue-media-results, .media-status-metrics { grid-template-columns: 1fr; } @@ -3231,6 +3409,7 @@ button:disabled, .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-target-heading { align-items: flex-start; 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 0184bc1..fb4d351 100644 --- a/frontend/app/portal/PortalClient.tsx +++ b/frontend/app/portal/PortalClient.tsx @@ -1,7 +1,7 @@ 'use client' -import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' type PortalPermissions = { @@ -126,13 +126,45 @@ type MediaServerStatus = { } } -type ReplacementFile = { +type IssueEpisodeOption = { id: number - name: string - quality?: string | null - size?: number | null - season_number?: number | null - episodes?: string[] + season_number: number + episode_number: number + code: string + title: string + released: boolean + monitored: boolean + has_file: boolean + missing: boolean + best_fit: boolean + file_id?: number | null +} + +type IssueSeasonOption = { + season_number: number + label: string + episode_count: number + available_count: number + missing_count: number + best_fit: boolean +} + +type IssueTargetOptions = { + request_id: string + request_type: 'movie' | 'tv' + title: string + collector_id?: number | null + movie?: { + selected_label: string + has_file: boolean + missing: boolean + best_fit: boolean + file_id?: number | null + } | null + seasons: IssueSeasonOption[] + episodes: IssueEpisodeOption[] + can_act: boolean + message?: string } const ISSUE_CATEGORIES: Array<{ @@ -149,7 +181,7 @@ const ISSUE_CATEGORIES: Array<{ marker: 'REPLACE', label: 'Picture or file is broken', description: 'Corruption, visual artefacts, freezing, or playback stopping at the same point.', - outcome: 'Likely action: replace the affected media file.', + outcome: 'The affected file will be replaced automatically.', issueType: 'broken_media', titlePrefix: 'Replace media', }, @@ -158,7 +190,7 @@ const ISSUE_CATEGORIES: Array<{ marker: 'MISSING', label: 'Movie or episode is missing', description: 'A title, season, episode, or expected part is not available in Grizzlyflix.', - outcome: 'Likely action: check the request pipeline, then collect the missing media.', + outcome: 'The selected missing content will be sent back to Sonarr or Radarr.', issueType: 'missing_content', titlePrefix: 'Missing content', }, @@ -167,7 +199,7 @@ const ISSUE_CATEGORIES: Array<{ marker: 'AUDIO', label: 'Audio is wrong', description: 'No sound, wrong language, commentary only, distorted audio, or audio out of sync.', - outcome: 'Likely action: replace the file or correct its audio tracks.', + outcome: 'The affected file will be replaced automatically.', issueType: 'audio', titlePrefix: 'Audio problem', }, @@ -176,7 +208,7 @@ const ISSUE_CATEGORIES: Array<{ marker: 'SUBS', label: 'Subtitles are wrong', description: 'Missing, incorrect, forced, unreadable, or out-of-sync subtitles.', - outcome: 'Likely action: repair the subtitle track or replace the media.', + outcome: 'Bazarr will find a fresh subtitle without replacing the video.', issueType: 'subtitle', titlePrefix: 'Subtitle problem', }, @@ -185,7 +217,7 @@ const ISSUE_CATEGORIES: Array<{ marker: 'PLAYBACK', label: 'Playback or transcoding problem', description: 'The title will not start, constantly buffers, stops, or reports a transcode error.', - outcome: 'Magent will check Jellyfin before deciding whether this is file-, device-, or server-related.', + outcome: 'Magent will check Jellyfin and replace only the selected file when appropriate.', issueType: 'playback', titlePrefix: 'Playback problem', }, @@ -194,7 +226,7 @@ const ISSUE_CATEGORIES: Array<{ marker: 'SERVER', label: 'Nothing will play', description: 'Grizzlyflix will not open or every title fails across the device or household.', - outcome: 'Magent will check the media server and include the result with the report.', + outcome: 'Magent will check Jellyfin and attach the result to the issue.', issueType: 'service_unavailable', titlePrefix: 'Media server unavailable', }, @@ -209,6 +241,8 @@ const ISSUE_SYMPTOMS: Record = { service_unavailable: ['Grizzlyflix will not open', 'Every title fails', 'Login works but playback does not', 'Server error is shown'], } +const DEVICE_OPTIONS = ['TV app', 'Web browser', 'Phone or tablet', 'Multiple devices'] as const + const ISSUE_TYPE_LABELS: Record = { general: 'General', playback: 'Playback', @@ -311,13 +345,6 @@ const formatIssueStatus = (value?: string | null) => { return labels[String(value ?? '').toLowerCase()] ?? String(value ?? 'Unknown').replaceAll('_', ' ') } -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 @@ -385,11 +412,8 @@ export default function PortalClient({ workspace }: PortalClientProps) { const [issueCategory, setIssueCategory] = useState(null) const [issueMediaTitle, setIssueMediaTitle] = useState('') const [issueMediaType, setIssueMediaType] = useState<'movie' | 'tv'>('movie') - const [issueEpisode, setIssueEpisode] = useState('') - const [issueScope, setIssueScope] = useState<'one_title' | 'multiple_titles' | 'everything'>('one_title') - const [issueSymptom, setIssueSymptom] = useState('') - const [issueDevice, setIssueDevice] = useState('') - const [issueNotes, setIssueNotes] = useState('') + const [issueSymptoms, setIssueSymptoms] = useState([]) + const [issueDevices, setIssueDevices] = useState([]) const [mediaServerStatus, setMediaServerStatus] = useState(null) const [mediaServerChecking, setMediaServerChecking] = useState(false) const [mediaServerError, setMediaServerError] = useState(null) @@ -397,13 +421,13 @@ export default function PortalClient({ workspace }: PortalClientProps) { 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 [issueOptions, setIssueOptions] = useState(null) + const [issueOptionsLoading, setIssueOptionsLoading] = useState(false) + const [issueOptionsMessage, setIssueOptionsMessage] = useState(null) + const [movieTargetSelected, setMovieTargetSelected] = useState(false) + const [activeSeasonNumber, setActiveSeasonNumber] = useState(null) + const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState([]) + const [selectedEpisodeIds, setSelectedEpisodeIds] = useState([]) const isAdmin = me?.role === 'admin' const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0) @@ -411,24 +435,50 @@ export default function PortalClient({ workspace }: PortalClientProps) { const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues' const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null const issueNeedsServerCheck = issueCategory === 'playback' || issueCategory === 'service_unavailable' - const issueNeedsMediaTitle = - Boolean(issueCategory) && - issueCategory !== 'service_unavailable' && - !(issueCategory === 'playback' && issueScope !== 'one_title') - const issueSupportsReplacement = + const issueNeedsMediaTitle = Boolean(issueCategory) + const issueRequiresExistingFile = 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)) - }) + issueCategory === 'playback' + const issueSupportsReplacement = + issueCategory === 'broken_media' || + issueCategory === 'audio' || + (issueCategory === 'playback' && mediaServerStatus?.status !== 'down') + const selectedEpisodeOptions = (issueOptions?.episodes ?? []).filter((episode) => + selectedEpisodeIds.includes(episode.id) + ) + const selectedReplacementFileIds = Array.from(new Set( + selectedEpisodeOptions + .map((episode) => episode.file_id) + .filter((fileId): fileId is number => typeof fileId === 'number' && fileId > 0) + )) + const missingEntireTitle = issueSymptoms.includes('Entire title is missing') + const missingSeasons = issueSymptoms.includes('Season is missing') + const missingEpisodes = issueSymptoms.includes('Episode is missing') || issueSymptoms.includes('Part or edition is missing') + const selectedMissingEpisodeIds = (issueOptions?.episodes ?? []) + .filter((episode) => { + if (missingEntireTitle) return episode.missing + if (missingEpisodes && selectedEpisodeIds.includes(episode.id)) return true + return missingSeasons && episode.missing && selectedSeasonNumbers.includes(episode.season_number) + }) + .map((episode) => episode.id) + const movieTargetAvailable = !issueRequiresExistingFile || Boolean(issueOptions?.movie?.has_file) + const issueTargetReady = Boolean( + issueOptions && + issueSymptoms.length > 0 && + ( + issueOptions.request_type === 'movie' + ? movieTargetSelected && movieTargetAvailable + : issueCategory === 'missing_content' + ? ( + missingEntireTitle || + ((missingSeasons ? selectedSeasonNumbers.length > 0 : true) && + (missingEpisodes ? selectedEpisodeIds.length > 0 : true)) + ) + : selectedEpisodeIds.length > 0 + ) + ) useEffect(() => { if (typeof window === 'undefined') return @@ -676,23 +726,23 @@ export default function PortalClient({ workspace }: PortalClientProps) { } } - const loadReplacementFiles = async (media: DiscoveryResult) => { - setReplacementFiles([]) - setSelectedReplacementFileId(null) - setStartReplacement(false) - setReplacementAllowed(true) - setReplacementFileFilter('') + const loadIssueOptions = async (media: DiscoveryResult) => { + setIssueOptions(null) + setMovieTargetSelected(false) + setActiveSeasonNumber(null) + setSelectedSeasonNumbers([]) + setSelectedEpisodeIds([]) if (!media.requestId) { - setReplacementFilesMessage( - 'This title is not linked to an existing Magent request, so there is no managed file to replace.' + setIssueOptionsMessage( + 'This title is not linked to a Magent request yet.' ) return } - setReplacementFilesLoading(true) - setReplacementFilesMessage(null) + setIssueOptionsLoading(true) + setIssueOptionsMessage(null) try { const response = await authFetch( - `${getApiBase()}/requests/${media.requestId}/replacement-options` + `${getApiBase()}/requests/${media.requestId}/issue-options` ) if (!response.ok) { if (response.status === 401) { @@ -701,27 +751,20 @@ export default function PortalClient({ workspace }: PortalClientProps) { 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) + throw new Error(text || 'Could not load seasons and episodes from Sonarr/Radarr.') } + const payload = await response.json() as IssueTargetOptions + setIssueOptions(payload) + setIssueOptionsMessage(payload.message ?? null) + const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0] + setActiveSeasonNumber(firstSeason?.season_number ?? null) } catch (err) { console.error(err) - setReplacementFilesMessage( - err instanceof Error ? err.message : 'Could not load the managed files from Sonarr/Radarr.' + setIssueOptionsMessage( + err instanceof Error ? err.message : 'Could not load seasons and episodes from Sonarr/Radarr.' ) } finally { - setReplacementFilesLoading(false) + setIssueOptionsLoading(false) } } @@ -737,10 +780,11 @@ export default function PortalClient({ workspace }: PortalClientProps) { setIssueMediaResults([]) setIssueSelectedMedia(null) setIssueMediaTitle('') - setReplacementFiles([]) - setSelectedReplacementFileId(null) - setStartReplacement(false) - setReplacementAllowed(true) + setIssueOptions(null) + setMovieTargetSelected(false) + setActiveSeasonNumber(null) + setSelectedSeasonNumbers([]) + setSelectedEpisodeIds([]) try { const response = await authFetch( `${getApiBase()}/requests/search?query=${encodeURIComponent(query)}` @@ -789,14 +833,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { setIssueMediaResults([]) setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`) setError(null) - if (issueSupportsReplacement) { - void loadReplacementFiles(media) - } else { - setReplacementFiles([]) - setSelectedReplacementFileId(null) - setStartReplacement(false) - setReplacementFilesMessage(null) - } + void loadIssueOptions(media) } const checkMediaServer = async () => { @@ -827,32 +864,48 @@ export default function PortalClient({ workspace }: PortalClientProps) { const chooseIssueCategory = (category: IssueCategoryId) => { setIssueCategory(category) - setIssueSymptom(ISSUE_SYMPTOMS[category][0] ?? '') - setIssueScope(category === 'service_unavailable' ? 'everything' : 'one_title') + setIssueSymptoms([]) + setIssueDevices([]) setMediaServerStatus(null) 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) - } + setMovieTargetSelected(false) + setSelectedSeasonNumbers([]) + setSelectedEpisodeIds([]) if (category === 'playback' || category === 'service_unavailable') { void checkMediaServer() } } + const toggleStringChoice = ( + value: string, + selected: string[], + setter: React.Dispatch>, + ) => { + setter(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]) + } + + const runIssueAction = async (path: string, body: Record): Promise => { + const response = await authFetch(`${getApiBase()}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) { + let detail = 'The follow-up action could not be started.' + try { + const payload = await response.json() + if (typeof payload?.detail === 'string') detail = payload.detail + } catch { + // Keep the plain-language fallback. + } + throw new Error(detail) + } + const payload = await response.json() + return typeof payload?.message === 'string' ? payload.message : 'The follow-up action started.' + } + const createGuidedIssue = async (event: React.FormEvent) => { event.preventDefault() if (!selectedIssueDefinition || !issueCategory) { @@ -860,40 +913,43 @@ export default function PortalClient({ workspace }: PortalClientProps) { return } const cleanMediaTitle = issueMediaTitle.trim() - if (issueNeedsMediaTitle && (!cleanMediaTitle || !issueSelectedMedia)) { - setError('Search for and select the exact movie or TV show so the managed file can be identified.') + if (!cleanMediaTitle || !issueSelectedMedia?.requestId || !issueOptions) { + setError('Search for and select a tracked movie or TV show first.') return } - if (startReplacement && (!issueSelectedMedia?.requestId || !selectedReplacementFile)) { - setError('Select the exact Sonarr/Radarr file before starting a replacement.') + if (issueSymptoms.length === 0) { + setError('Choose what needs to be corrected.') return } - if (startReplacement && !replacementAllowed) { - setError('Automatic replacement is disabled for this account. The issue can still be submitted.') + const isMovie = issueOptions.request_type === 'movie' + if (isMovie && !movieTargetSelected) { + setError('Select the movie to continue.') 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.' - ) - ) { + if (!isMovie && issueCategory === 'missing_content' && missingSeasons && selectedSeasonNumbers.length === 0) { + setError('Choose at least one missing season.') + return + } + if (!isMovie && issueCategory === 'missing_content' && missingEpisodes && selectedEpisodeIds.length === 0) { + setError('Choose at least one missing episode.') + return + } + if (!isMovie && issueCategory !== 'missing_content' && selectedEpisodeIds.length === 0) { + setError('Choose at least one affected episode.') + return + } + const movieFileId = issueOptions.movie?.file_id + const actionFileIds = isMovie + ? (typeof movieFileId === 'number' ? [movieFileId] : []) + : selectedReplacementFileIds + if (issueSupportsReplacement && actionFileIds.length === 0) { + setError('Sonarr/Radarr does not report a replaceable file for the selected content.') return } - setCreating(true) setError(null) setStatus(null) try { - const scopeLabel = - issueScope === 'everything' - ? 'Everything / service-wide' - : issueScope === 'multiple_titles' - ? 'Multiple titles' - : 'One title' const diagnosticLines: string[] = [] if (mediaServerStatus) { diagnosticLines.push( @@ -918,24 +974,21 @@ export default function PortalClient({ workspace }: PortalClientProps) { const description = [ `Problem: ${selectedIssueDefinition.label}`, - `Symptom: ${issueSymptom}`, - `Scope: ${scopeLabel}`, + `What needs correction: ${issueSymptoms.join(', ')}`, 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, + selectedSeasonNumbers.length ? `Seasons: ${selectedSeasonNumbers.map((season) => `Season ${season}`).join(', ')}` : null, + selectedEpisodeOptions.length ? `Episodes: ${selectedEpisodeOptions.map((episode) => episode.code).join(', ')}` : null, + issueDevices.length ? `Devices: ${issueDevices.join(', ')}` : null, ...diagnosticLines, - issueNotes.trim() ? `Additional information: ${issueNotes.trim()}` : null, ] .filter((line): line is string => Boolean(line)) .join('\n') - const titleTarget = cleanMediaTitle || (issueScope === 'everything' ? 'all playback' : 'multiple titles') + const titleTarget = cleanMediaTitle const resolvedIssueType = - issueCategory === 'playback' && issueSymptom.toLowerCase().includes('transcode') + issueCategory === 'playback' && issueSymptoms.some((symptom) => symptom.toLowerCase().includes('transcode')) ? 'transcode' : selectedIssueDefinition.issueType const priority = @@ -969,56 +1022,50 @@ export default function PortalClient({ workspace }: PortalClientProps) { const data = await response.json() const item = data?.item as PortalItem | undefined 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}` - } + ? `Issue #${item.id} submitted.` + : 'Issue submitted.' + const requestId = issueSelectedMedia.requestId + const actionBase = `/requests/${requestId}/actions` + let actionMessage = '' + if (issueOptions.can_act === false) { + actionMessage = 'Support has been given the selected title and affected content.' + } else if (issueCategory === 'missing_content') { + actionMessage = await runIssueAction(`${actionBase}/search-missing`, { + issue_id: item?.id ?? null, + episode_ids: selectedMissingEpisodeIds, + season_numbers: selectedSeasonNumbers, + }) + } else if (issueCategory === 'subtitle') { + actionMessage = await runIssueAction(`${actionBase}/repair-subtitles`, { + issue_id: item?.id ?? null, + episode_ids: isMovie ? [] : selectedEpisodeIds, + forced: issueSymptoms.includes('Forced subtitles are missing'), + }) + } else if (issueSupportsReplacement) { + actionMessage = await runIssueAction(`${actionBase}/replace`, { + issue_id: item?.id ?? null, + file_ids: actionFileIds, + confirmed: true, + }) } + if (actionMessage) completionMessage = `${completionMessage} ${actionMessage}` setStatus(completionMessage) - setError(replacementFailure) + setError(null) setIssueCategory(null) setIssueMediaTitle('') setIssueMediaType('movie') - setIssueEpisode('') - setIssueScope('one_title') - setIssueSymptom('') - setIssueDevice('') - setIssueNotes('') + setIssueSymptoms([]) + setIssueDevices([]) setMediaServerStatus(null) setIssueMediaQuery('') setIssueMediaResults([]) setIssueSelectedMedia(null) - setReplacementFiles([]) - setReplacementFilesMessage(null) - setReplacementFileFilter('') - setSelectedReplacementFileId(null) - setStartReplacement(false) - setReplacementAllowed(true) + setIssueOptions(null) + setIssueOptionsMessage(null) + setMovieTargetSelected(false) + setActiveSeasonNumber(null) + setSelectedSeasonNumbers([]) + setSelectedEpisodeIds([]) await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()]) } catch (err) { console.error(err) @@ -1412,7 +1459,6 @@ export default function PortalClient({ workspace }: PortalClientProps) { {category.marker} {category.label}

{category.description}

- {category.outcome} ))} @@ -1428,37 +1474,6 @@ export default function PortalClient({ workspace }: PortalClientProps) { - {issueCategory === 'playback' ? ( -
- How widespread is it? -
- {[ - ['one_title', 'One title'], - ['multiple_titles', 'Several titles'], - ['everything', 'Nothing will play'], - ].map(([value, label]) => ( - - ))} -
-
- ) : null} -
{issueNeedsMediaTitle ? (
@@ -1472,9 +1487,11 @@ export default function PortalClient({ workspace }: PortalClientProps) { if (issueSelectedMedia) { setIssueSelectedMedia(null) setIssueMediaTitle('') - setReplacementFiles([]) - setSelectedReplacementFileId(null) - setStartReplacement(false) + setIssueOptions(null) + setMovieTargetSelected(false) + setActiveSeasonNumber(null) + setSelectedSeasonNumbers([]) + setSelectedEpisodeIds([]) } }} placeholder="Search the Grizzlyflix catalogue" @@ -1534,105 +1551,177 @@ export default function PortalClient({ workspace }: PortalClientProps) {
) : 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} - {issueNeedsMediaTitle && issueMediaType === 'tv' ? ( - + {issueTargetReady && (issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? ( +
+ Where did it happen? Choose all that apply +
+ {DEVICE_OPTIONS.map((device) => ( + + ))} +
+
) : null} - - {(issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? ( - - ) : null} -