Streamline issue reporting and automate repairs
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s

This commit is contained in:
2026-09-01 13:44:13 +12:00
parent 2adbed7259
commit e58614305e
12 changed files with 1223 additions and 333 deletions
+8
View File
@@ -11,6 +11,7 @@ _SERVICE_NAMES = {
"JellyseerrClient": "Seerr", "JellyseerrClient": "Seerr",
"SonarrClient": "Sonarr", "SonarrClient": "Sonarr",
"RadarrClient": "Radarr", "RadarrClient": "Radarr",
"BazarrClient": "Bazarr",
"ProwlarrClient": "Prowlarr", "ProwlarrClient": "Prowlarr",
"JellyfinClient": "Jellyfin", "JellyfinClient": "Jellyfin",
"QBittorrentClient": "qBittorrent", "QBittorrentClient": "qBittorrent",
@@ -184,6 +185,11 @@ def _operation_result_message(
else "Prowlarr did not find any possible releases." 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": if normalized_method == "GET":
return f"{service} completed the check successfully." return f"{service} completed the check successfully."
if normalized_method == "POST": 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" return f"Checking releases through {service}", f"{service} returned release information"
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path: if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
return f"Sending a command to {service}", f"{service} accepted the command" 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: if service == "Prowlarr" and "/health" in normalized_path:
return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health" return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health"
return f"Contacting {service}", f"{service} responded" return f"Contacting {service}", f"{service} responded"
+48
View File
@@ -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,
)
+10
View File
@@ -305,6 +305,16 @@ class Settings(BaseSettings):
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"), 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( prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL") default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
) )
+5
View File
@@ -135,6 +135,7 @@ SENSITIVE_KEYS = {
"jellyfin_api_key", "jellyfin_api_key",
"sonarr_api_key", "sonarr_api_key",
"radarr_api_key", "radarr_api_key",
"bazarr_api_key",
"prowlarr_api_key", "prowlarr_api_key",
"qbittorrent_password", "qbittorrent_password",
} }
@@ -150,6 +151,7 @@ URL_SETTING_KEYS = {
"jellyfin_public_url", "jellyfin_public_url",
"sonarr_base_url", "sonarr_base_url",
"radarr_base_url", "radarr_base_url",
"bazarr_base_url",
"prowlarr_base_url", "prowlarr_base_url",
"qbittorrent_base_url", "qbittorrent_base_url",
} }
@@ -216,6 +218,9 @@ SETTING_KEYS: List[str] = [
"radarr_quality_profile_id", "radarr_quality_profile_id",
"radarr_root_folder", "radarr_root_folder",
"radarr_qbittorrent_category", "radarr_qbittorrent_category",
"bazarr_base_url",
"bazarr_api_key",
"bazarr_default_language",
"prowlarr_base_url", "prowlarr_base_url",
"prowlarr_api_key", "prowlarr_api_key",
"qbittorrent_base_url", "qbittorrent_base_url",
+364 -21
View File
@@ -14,6 +14,7 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
from ..clients.bazarr import BazarrClient
from ..ai.triage import triage_snapshot from ..ai.triage import triage_snapshot
from ..auth import get_current_user from ..auth import get_current_user
from ..runtime import get_runtime_settings 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: async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
if root_folder.isdigit(): if root_folder.isdigit():
folders = await client.get_root_folders() folders = await client.get_root_folders()
@@ -1851,6 +1923,80 @@ async def _resolve_root_folder_path(client: Any, root_folder: str, service_name:
return root_folder 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") @router.get("/{request_id}/replacement-options")
async def replacement_options( async def replacement_options(
request_id: str, 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") raise HTTPException(status_code=403, detail="Media replacement is disabled for this user")
if payload.get("confirmed") is not True: if payload.get("confirmed") is not True:
raise HTTPException(status_code=400, detail="Replacement confirmation is required") raise HTTPException(status_code=400, detail="Replacement confirmation is required")
file_id = payload.get("file_id") raw_file_ids = payload.get("file_ids")
if not isinstance(file_id, int) or file_id <= 0: if raw_file_ids is None:
raise HTTPException(status_code=400, detail="A valid managed file is required") 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( linked_issue = _linked_issue_for_replacement(
payload.get("issue_id"), payload.get("issue_id"),
request_id=request_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") raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr" collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
target_name = "managed media file" target_names: List[str] = []
try: try:
if snapshot.request_type == RequestType.movie: if snapshot.request_type == RequestType.movie:
movie_id = arr_item.get("id") movie_id = arr_item.get("id")
movie_file = arr_item.get("movieFile") movie_file = arr_item.get("movieFile")
if not isinstance(movie_id, int) or not isinstance(movie_file, dict): 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") 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") 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) radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not radarr.configured(): if not radarr.configured():
raise HTTPException(status_code=400, detail="Radarr is not 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) await radarr.search(movie_id)
elif snapshot.request_type == RequestType.tv: elif snapshot.request_type == RequestType.tv:
series_id = arr_item.get("id") series_id = arr_item.get("id")
@@ -1999,27 +2154,25 @@ async def action_replace_media(
sonarr.get_episode_files(series_id), sonarr.get_episode_files(series_id),
sonarr.get_episodes(series_id), sonarr.get_episodes(series_id),
) )
selected_file = next( selected_files = [
(
item item
for item in episode_files for item in episode_files
if isinstance(item, dict) and item.get("id") == file_id if isinstance(item, dict) and item.get("id") in file_ids
), ] if isinstance(episode_files, list) else []
None, if len(selected_files) != len(file_ids):
) if isinstance(episode_files, list) else None raise HTTPException(status_code=409, detail="One or more selected episode files are no longer current")
if not isinstance(selected_file, dict):
raise HTTPException(status_code=409, detail="The selected episode file is no longer current")
episode_ids = [ episode_ids = [
episode.get("id") episode.get("id")
for episode in episodes for episode in episodes
if isinstance(episode, dict) if isinstance(episode, dict)
and episode.get("episodeFileId") == file_id and episode.get("episodeFileId") in file_ids
and isinstance(episode.get("id"), int) and isinstance(episode.get("id"), int)
] if isinstance(episodes, list) else [] ] if isinstance(episodes, list) else []
if not episode_ids: if not episode_ids:
raise HTTPException(status_code=409, detail="Sonarr could not match episodes to this file") raise HTTPException(status_code=409, detail="Sonarr could not match episodes to this file")
target_name = _replacement_file_name(selected_file) target_names = [_replacement_file_name(file_data) for file_data in selected_files]
await sonarr.delete_episode_file(file_id) for selected_file_id in file_ids:
await sonarr.delete_episode_file(selected_file_id)
await sonarr.search_episodes(episode_ids) await sonarr.search_episodes(episode_ids)
else: else:
raise HTTPException(status_code=400, detail="Unknown request type") raise HTTPException(status_code=400, detail="Unknown request type")
@@ -2032,7 +2185,7 @@ async def action_replace_media(
) )
raise raise
except Exception as exc: 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 = ( detail = (
f"{collector} could not complete the replacement. Check the request action history " f"{collector} could not complete the replacement. Check the request action history "
"before trying again." "before trying again."
@@ -2053,7 +2206,8 @@ async def action_replace_media(
) )
raise HTTPException(status_code=502, detail=detail) from exc 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( await asyncio.to_thread(
save_action, save_action,
request_id, request_id,
@@ -2073,10 +2227,199 @@ async def action_replace_media(
"message": message, "message": message,
"collector": collector, "collector": collector,
"request_id": request_id, "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) @router.get("/{request_id}/snapshot", response_model=Snapshot)
async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot: async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot:
runtime = get_runtime_settings() runtime = get_runtime_settings()
+15
View File
@@ -7,6 +7,7 @@ from ..runtime import get_runtime_settings
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
from ..clients.radarr import RadarrClient from ..clients.radarr import RadarrClient
from ..clients.bazarr import BazarrClient
from ..clients.prowlarr import ProwlarrClient from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient 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) jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_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) prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient( qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password 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, 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_status = await _check(
"Prowlarr", "Prowlarr",
prowlarr.configured(), 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) jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_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) prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient( qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password 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), "sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.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), "prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info), "jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
} }
+163
View File
@@ -254,6 +254,20 @@ class OperationMessageTests(unittest.TestCase):
"The title is not currently available in Jellyfin.", "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: def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None:
self.assertEqual( self.assertEqual(
_operation_error_message("Radarr", 500), _operation_error_message("Radarr", 500),
@@ -1359,6 +1373,155 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result["files"][0]["episodes"], ["S02E03"]) self.assertEqual(result["files"][0]["episodes"], ["S02E03"])
self.assertNotIn("/private/library", str(result)) 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): class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None: def setUp(self) -> None:
+23 -3
View File
@@ -1,10 +1,10 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import AdminShell from '../ui/AdminShell' import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../lib/auth'
import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel' import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
import AdminShell from '../ui/AdminShell'
type AdminSetting = { type AdminSetting = {
key: string key: string
@@ -36,6 +36,7 @@ const SECTION_LABELS: Record<string, string> = {
cache: 'Request cache', cache: 'Request cache',
sonarr: 'Sonarr', sonarr: 'Sonarr',
radarr: 'Radarr', radarr: 'Radarr',
bazarr: 'Bazarr',
prowlarr: 'Prowlarr', prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent', qbittorrent: 'qBittorrent',
logs: 'Activity log', logs: 'Activity log',
@@ -83,6 +84,7 @@ const URL_SETTINGS = new Set([
'jellyfin_public_url', 'jellyfin_public_url',
'sonarr_base_url', 'sonarr_base_url',
'radarr_base_url', 'radarr_base_url',
'bazarr_base_url',
'prowlarr_base_url', 'prowlarr_base_url',
'qbittorrent_base_url', 'qbittorrent_base_url',
]) ])
@@ -115,6 +117,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
cache: 'Manage saved requests cache and refresh behavior.', cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'Sonarr connection and the default profile and library location for TV requests.', 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.', 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.', prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.', qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
requests: 'Control how often requests are refreshed and cleaned up.', requests: 'Control how often requests are refreshed and cleaned up.',
@@ -134,6 +137,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
artwork: null, artwork: null,
sonarr: 'sonarr', sonarr: 'sonarr',
radarr: 'radarr', radarr: 'radarr',
bazarr: 'bazarr',
prowlarr: 'prowlarr', prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent', qbittorrent: 'qbittorrent',
requests: 'requests', requests: 'requests',
@@ -361,6 +365,14 @@ const STANDARD_SECTION_GROUPS: Record<
keys: ['radarr_quality_profile_id', 'radarr_root_folder'], 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: [ prowlarr: [
{ {
key: 'prowlarr-connection', key: 'prowlarr-connection',
@@ -430,6 +442,9 @@ const STANDARD_SECTION_GROUPS: Record<
} }
const SETTING_LABEL_OVERRIDES: Record<string, string> = { const SETTING_LABEL_OVERRIDES: Record<string, string> = {
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_contact_attempts: 'Confirmation emails before auto-close',
issue_confirmation_interval_value: 'Confirmation interval', issue_confirmation_interval_value: 'Confirmation interval',
issue_confirmation_interval_unit: 'Interval unit', 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.', 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_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.', 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_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores 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.', 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_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com', jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989', 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', 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', 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', qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
+1
View File
@@ -8,6 +8,7 @@ const ALLOWED_SECTIONS = new Set([
'artwork', 'artwork',
'sonarr', 'sonarr',
'radarr', 'radarr',
'bazarr',
'prowlarr', 'prowlarr',
'qbittorrent', 'qbittorrent',
'requests', 'requests',
+179
View File
@@ -2977,6 +2977,178 @@ button:disabled,
gap: 3px; 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 { .issue-file-picker {
display: grid; display: grid;
gap: 11px; gap: 11px;
@@ -3212,6 +3384,9 @@ button:disabled,
@media (max-width: 980px) { @media (max-width: 980px) {
.issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .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)); } .media-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@@ -3222,6 +3397,9 @@ button:disabled,
.issue-hero-count { width: 100%; } .issue-hero-count { width: 100%; }
.issue-flow { padding: 14px; } .issue-flow { padding: 14px; }
.issue-category-grid, .issue-category-grid,
.issue-choice-grid,
.issue-season-grid,
.issue-episode-grid,
.issue-question-grid, .issue-question-grid,
.issue-media-results, .issue-media-results,
.media-status-metrics { grid-template-columns: 1fr; } .media-status-metrics { grid-template-columns: 1fr; }
@@ -3231,6 +3409,7 @@ button:disabled,
.issue-resolution-card > button { grid-column: 1 / -1; width: 100%; } .issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
.issue-media-search-row { grid-template-columns: 1fr; } .issue-media-search-row { grid-template-columns: 1fr; }
.issue-selected-media { align-items: stretch; flex-direction: column; } .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-linked-request { align-items: stretch; flex-direction: column; }
.issue-file-list > label { grid-template-columns: auto minmax(0, 1fr); } .issue-file-list > label { grid-template-columns: auto minmax(0, 1fr); }
.issue-file-list > label > b { grid-column: 2; } .issue-file-list > label > b { grid-column: 2; }
+402 -305
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type PortalPermissions = { type PortalPermissions = {
@@ -126,13 +126,45 @@ type MediaServerStatus = {
} }
} }
type ReplacementFile = { type IssueEpisodeOption = {
id: number id: number
name: string season_number: number
quality?: string | null episode_number: number
size?: number | null code: string
season_number?: number | null title: string
episodes?: 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<{ const ISSUE_CATEGORIES: Array<{
@@ -149,7 +181,7 @@ const ISSUE_CATEGORIES: Array<{
marker: 'REPLACE', marker: 'REPLACE',
label: 'Picture or file is broken', label: 'Picture or file is broken',
description: 'Corruption, visual artefacts, freezing, or playback stopping at the same point.', 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', issueType: 'broken_media',
titlePrefix: 'Replace media', titlePrefix: 'Replace media',
}, },
@@ -158,7 +190,7 @@ const ISSUE_CATEGORIES: Array<{
marker: 'MISSING', marker: 'MISSING',
label: 'Movie or episode is missing', label: 'Movie or episode is missing',
description: 'A title, season, episode, or expected part is not available in Grizzlyflix.', 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', issueType: 'missing_content',
titlePrefix: 'Missing content', titlePrefix: 'Missing content',
}, },
@@ -167,7 +199,7 @@ const ISSUE_CATEGORIES: Array<{
marker: 'AUDIO', marker: 'AUDIO',
label: 'Audio is wrong', label: 'Audio is wrong',
description: 'No sound, wrong language, commentary only, distorted audio, or audio out of sync.', 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', issueType: 'audio',
titlePrefix: 'Audio problem', titlePrefix: 'Audio problem',
}, },
@@ -176,7 +208,7 @@ const ISSUE_CATEGORIES: Array<{
marker: 'SUBS', marker: 'SUBS',
label: 'Subtitles are wrong', label: 'Subtitles are wrong',
description: 'Missing, incorrect, forced, unreadable, or out-of-sync subtitles.', 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', issueType: 'subtitle',
titlePrefix: 'Subtitle problem', titlePrefix: 'Subtitle problem',
}, },
@@ -185,7 +217,7 @@ const ISSUE_CATEGORIES: Array<{
marker: 'PLAYBACK', marker: 'PLAYBACK',
label: 'Playback or transcoding problem', label: 'Playback or transcoding problem',
description: 'The title will not start, constantly buffers, stops, or reports a transcode error.', 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', issueType: 'playback',
titlePrefix: 'Playback problem', titlePrefix: 'Playback problem',
}, },
@@ -194,7 +226,7 @@ const ISSUE_CATEGORIES: Array<{
marker: 'SERVER', marker: 'SERVER',
label: 'Nothing will play', label: 'Nothing will play',
description: 'Grizzlyflix will not open or every title fails across the device or household.', 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', issueType: 'service_unavailable',
titlePrefix: 'Media server unavailable', titlePrefix: 'Media server unavailable',
}, },
@@ -209,6 +241,8 @@ const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
service_unavailable: ['Grizzlyflix will not open', 'Every title fails', 'Login works but playback does not', 'Server error is shown'], 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<string, string> = { const ISSUE_TYPE_LABELS: Record<string, string> = {
general: 'General', general: 'General',
playback: 'Playback', playback: 'Playback',
@@ -311,13 +345,6 @@ const formatIssueStatus = (value?: string | null) => {
return labels[String(value ?? '').toLowerCase()] ?? String(value ?? 'Unknown').replaceAll('_', ' ') 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 toPositiveInt = (value: string) => {
const parsed = Number.parseInt(value, 10) const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed) || parsed <= 0) return null if (Number.isNaN(parsed) || parsed <= 0) return null
@@ -385,11 +412,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [issueCategory, setIssueCategory] = useState<IssueCategoryId | null>(null) const [issueCategory, setIssueCategory] = useState<IssueCategoryId | null>(null)
const [issueMediaTitle, setIssueMediaTitle] = useState('') const [issueMediaTitle, setIssueMediaTitle] = useState('')
const [issueMediaType, setIssueMediaType] = useState<'movie' | 'tv'>('movie') const [issueMediaType, setIssueMediaType] = useState<'movie' | 'tv'>('movie')
const [issueEpisode, setIssueEpisode] = useState('') const [issueSymptoms, setIssueSymptoms] = useState<string[]>([])
const [issueScope, setIssueScope] = useState<'one_title' | 'multiple_titles' | 'everything'>('one_title') const [issueDevices, setIssueDevices] = useState<string[]>([])
const [issueSymptom, setIssueSymptom] = useState('')
const [issueDevice, setIssueDevice] = useState('')
const [issueNotes, setIssueNotes] = useState('')
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null) const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
const [mediaServerChecking, setMediaServerChecking] = useState(false) const [mediaServerChecking, setMediaServerChecking] = useState(false)
const [mediaServerError, setMediaServerError] = useState<string | null>(null) const [mediaServerError, setMediaServerError] = useState<string | null>(null)
@@ -397,13 +421,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [issueMediaSearching, setIssueMediaSearching] = useState(false) const [issueMediaSearching, setIssueMediaSearching] = useState(false)
const [issueMediaResults, setIssueMediaResults] = useState<DiscoveryResult[]>([]) const [issueMediaResults, setIssueMediaResults] = useState<DiscoveryResult[]>([])
const [issueSelectedMedia, setIssueSelectedMedia] = useState<DiscoveryResult | null>(null) const [issueSelectedMedia, setIssueSelectedMedia] = useState<DiscoveryResult | null>(null)
const [replacementFiles, setReplacementFiles] = useState<ReplacementFile[]>([]) const [issueOptions, setIssueOptions] = useState<IssueTargetOptions | null>(null)
const [replacementFilesLoading, setReplacementFilesLoading] = useState(false) const [issueOptionsLoading, setIssueOptionsLoading] = useState(false)
const [replacementFilesMessage, setReplacementFilesMessage] = useState<string | null>(null) const [issueOptionsMessage, setIssueOptionsMessage] = useState<string | null>(null)
const [replacementFileFilter, setReplacementFileFilter] = useState('') const [movieTargetSelected, setMovieTargetSelected] = useState(false)
const [selectedReplacementFileId, setSelectedReplacementFileId] = useState<number | null>(null) const [activeSeasonNumber, setActiveSeasonNumber] = useState<number | null>(null)
const [startReplacement, setStartReplacement] = useState(false) const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([])
const [replacementAllowed, setReplacementAllowed] = useState(true) const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([])
const isAdmin = me?.role === 'admin' const isAdmin = me?.role === 'admin'
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0) const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
@@ -411,24 +435,50 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues' const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues'
const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null
const issueNeedsServerCheck = issueCategory === 'playback' || issueCategory === 'service_unavailable' const issueNeedsServerCheck = issueCategory === 'playback' || issueCategory === 'service_unavailable'
const issueNeedsMediaTitle = const issueNeedsMediaTitle = Boolean(issueCategory)
Boolean(issueCategory) && const issueRequiresExistingFile =
issueCategory !== 'service_unavailable' &&
!(issueCategory === 'playback' && issueScope !== 'one_title')
const issueSupportsReplacement =
issueCategory === 'broken_media' || issueCategory === 'broken_media' ||
issueCategory === 'audio' || issueCategory === 'audio' ||
issueCategory === 'subtitle' || issueCategory === 'subtitle' ||
(issueCategory === 'playback' && issueScope === 'one_title') issueCategory === 'playback'
const selectedReplacementFile = const issueSupportsReplacement =
replacementFiles.find((file) => file.id === selectedReplacementFileId) ?? null issueCategory === 'broken_media' ||
const visibleReplacementFiles = replacementFiles.filter((file) => { issueCategory === 'audio' ||
const query = replacementFileFilter.trim().toLowerCase() (issueCategory === 'playback' && mediaServerStatus?.status !== 'down')
if (!query) return true const selectedEpisodeOptions = (issueOptions?.episodes ?? []).filter((episode) =>
return [file.name, file.quality, ...(file.episodes ?? [])] selectedEpisodeIds.includes(episode.id)
.filter(Boolean) )
.some((value) => String(value).toLowerCase().includes(query)) 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(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
@@ -676,23 +726,23 @@ export default function PortalClient({ workspace }: PortalClientProps) {
} }
} }
const loadReplacementFiles = async (media: DiscoveryResult) => { const loadIssueOptions = async (media: DiscoveryResult) => {
setReplacementFiles([]) setIssueOptions(null)
setSelectedReplacementFileId(null) setMovieTargetSelected(false)
setStartReplacement(false) setActiveSeasonNumber(null)
setReplacementAllowed(true) setSelectedSeasonNumbers([])
setReplacementFileFilter('') setSelectedEpisodeIds([])
if (!media.requestId) { if (!media.requestId) {
setReplacementFilesMessage( setIssueOptionsMessage(
'This title is not linked to an existing Magent request, so there is no managed file to replace.' 'This title is not linked to a Magent request yet.'
) )
return return
} }
setReplacementFilesLoading(true) setIssueOptionsLoading(true)
setReplacementFilesMessage(null) setIssueOptionsMessage(null)
try { try {
const response = await authFetch( const response = await authFetch(
`${getApiBase()}/requests/${media.requestId}/replacement-options` `${getApiBase()}/requests/${media.requestId}/issue-options`
) )
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
@@ -701,27 +751,20 @@ export default function PortalClient({ workspace }: PortalClientProps) {
return return
} }
const text = await response.text() const text = await response.text()
throw new Error(text || 'Could not load the managed files from Sonarr/Radarr.') throw new Error(text || 'Could not load seasons and episodes 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)
} }
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) { } catch (err) {
console.error(err) console.error(err)
setReplacementFilesMessage( setIssueOptionsMessage(
err instanceof Error ? err.message : 'Could not load the managed files from Sonarr/Radarr.' err instanceof Error ? err.message : 'Could not load seasons and episodes from Sonarr/Radarr.'
) )
} finally { } finally {
setReplacementFilesLoading(false) setIssueOptionsLoading(false)
} }
} }
@@ -737,10 +780,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueMediaResults([]) setIssueMediaResults([])
setIssueSelectedMedia(null) setIssueSelectedMedia(null)
setIssueMediaTitle('') setIssueMediaTitle('')
setReplacementFiles([]) setIssueOptions(null)
setSelectedReplacementFileId(null) setMovieTargetSelected(false)
setStartReplacement(false) setActiveSeasonNumber(null)
setReplacementAllowed(true) setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
try { try {
const response = await authFetch( const response = await authFetch(
`${getApiBase()}/requests/search?query=${encodeURIComponent(query)}` `${getApiBase()}/requests/search?query=${encodeURIComponent(query)}`
@@ -789,14 +833,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueMediaResults([]) setIssueMediaResults([])
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`) setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`)
setError(null) setError(null)
if (issueSupportsReplacement) { void loadIssueOptions(media)
void loadReplacementFiles(media)
} else {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementFilesMessage(null)
}
} }
const checkMediaServer = async () => { const checkMediaServer = async () => {
@@ -827,32 +864,48 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const chooseIssueCategory = (category: IssueCategoryId) => { const chooseIssueCategory = (category: IssueCategoryId) => {
setIssueCategory(category) setIssueCategory(category)
setIssueSymptom(ISSUE_SYMPTOMS[category][0] ?? '') setIssueSymptoms([])
setIssueScope(category === 'service_unavailable' ? 'everything' : 'one_title') setIssueDevices([])
setMediaServerStatus(null) setMediaServerStatus(null)
setMediaServerError(null) setMediaServerError(null)
setError(null) setError(null)
setStatus(null) setStatus(null)
const supportsReplacement = ['broken_media', 'audio', 'subtitle', 'playback'].includes(category) setMovieTargetSelected(false)
if (category === 'service_unavailable') { setSelectedSeasonNumbers([])
setIssueSelectedMedia(null) setSelectedEpisodeIds([])
setIssueMediaTitle('')
setIssueMediaQuery('')
setIssueMediaResults([])
}
if (supportsReplacement && issueSelectedMedia?.requestId) {
void loadReplacementFiles(issueSelectedMedia)
} else if (!supportsReplacement) {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementFilesMessage(null)
}
if (category === 'playback' || category === 'service_unavailable') { if (category === 'playback' || category === 'service_unavailable') {
void checkMediaServer() void checkMediaServer()
} }
} }
const toggleStringChoice = (
value: string,
selected: string[],
setter: React.Dispatch<React.SetStateAction<string[]>>,
) => {
setter(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value])
}
const runIssueAction = async (path: string, body: Record<string, unknown>): Promise<string> => {
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) => { const createGuidedIssue = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
if (!selectedIssueDefinition || !issueCategory) { if (!selectedIssueDefinition || !issueCategory) {
@@ -860,40 +913,43 @@ export default function PortalClient({ workspace }: PortalClientProps) {
return return
} }
const cleanMediaTitle = issueMediaTitle.trim() const cleanMediaTitle = issueMediaTitle.trim()
if (issueNeedsMediaTitle && (!cleanMediaTitle || !issueSelectedMedia)) { if (!cleanMediaTitle || !issueSelectedMedia?.requestId || !issueOptions) {
setError('Search for and select the exact movie or TV show so the managed file can be identified.') setError('Search for and select a tracked movie or TV show first.')
return return
} }
if (startReplacement && (!issueSelectedMedia?.requestId || !selectedReplacementFile)) { if (issueSymptoms.length === 0) {
setError('Select the exact Sonarr/Radarr file before starting a replacement.') setError('Choose what needs to be corrected.')
return return
} }
if (startReplacement && !replacementAllowed) { const isMovie = issueOptions.request_type === 'movie'
setError('Automatic replacement is disabled for this account. The issue can still be submitted.') if (isMovie && !movieTargetSelected) {
setError('Select the movie to continue.')
return return
} }
if ( if (!isMovie && issueCategory === 'missing_content' && missingSeasons && selectedSeasonNumbers.length === 0) {
startReplacement && setError('Choose at least one missing season.')
selectedReplacementFile && return
!window.confirm( }
`Replace “${selectedReplacementFile.name}”?\n\n` + if (!isMovie && issueCategory === 'missing_content' && missingEpisodes && selectedEpisodeIds.length === 0) {
'The current managed file will be removed and Sonarr/Radarr will immediately search for a replacement. ' + setError('Choose at least one missing episode.')
'The title may be unavailable until the new download is imported.' 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 return
} }
setCreating(true) setCreating(true)
setError(null) setError(null)
setStatus(null) setStatus(null)
try { try {
const scopeLabel =
issueScope === 'everything'
? 'Everything / service-wide'
: issueScope === 'multiple_titles'
? 'Multiple titles'
: 'One title'
const diagnosticLines: string[] = [] const diagnosticLines: string[] = []
if (mediaServerStatus) { if (mediaServerStatus) {
diagnosticLines.push( diagnosticLines.push(
@@ -918,24 +974,21 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const description = [ const description = [
`Problem: ${selectedIssueDefinition.label}`, `Problem: ${selectedIssueDefinition.label}`,
`Symptom: ${issueSymptom}`, `What needs correction: ${issueSymptoms.join(', ')}`,
`Scope: ${scopeLabel}`,
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null, cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null, cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null,
issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null, issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null,
selectedReplacementFile ? `Managed file: ${selectedReplacementFile.name}` : null, selectedSeasonNumbers.length ? `Seasons: ${selectedSeasonNumbers.map((season) => `Season ${season}`).join(', ')}` : null,
startReplacement ? 'Replacement action: confirmed by the reporting user' : null, selectedEpisodeOptions.length ? `Episodes: ${selectedEpisodeOptions.map((episode) => episode.code).join(', ')}` : null,
issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null, issueDevices.length ? `Devices: ${issueDevices.join(', ')}` : null,
issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null,
...diagnosticLines, ...diagnosticLines,
issueNotes.trim() ? `Additional information: ${issueNotes.trim()}` : null,
] ]
.filter((line): line is string => Boolean(line)) .filter((line): line is string => Boolean(line))
.join('\n') .join('\n')
const titleTarget = cleanMediaTitle || (issueScope === 'everything' ? 'all playback' : 'multiple titles') const titleTarget = cleanMediaTitle
const resolvedIssueType = const resolvedIssueType =
issueCategory === 'playback' && issueSymptom.toLowerCase().includes('transcode') issueCategory === 'playback' && issueSymptoms.some((symptom) => symptom.toLowerCase().includes('transcode'))
? 'transcode' ? 'transcode'
: selectedIssueDefinition.issueType : selectedIssueDefinition.issueType
const priority = const priority =
@@ -969,56 +1022,50 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const data = await response.json() const data = await response.json()
const item = data?.item as PortalItem | undefined const item = data?.item as PortalItem | undefined
let completionMessage = item?.id let completionMessage = item?.id
? `Issue #${item.id} submitted with the troubleshooting details.` ? `Issue #${item.id} submitted.`
: 'Issue submitted with the troubleshooting details.' : 'Issue submitted.'
let replacementFailure: string | null = null const requestId = issueSelectedMedia.requestId
if (startReplacement && issueSelectedMedia?.requestId && selectedReplacementFile) { const actionBase = `/requests/${requestId}/actions`
const replacementResponse = await authFetch( let actionMessage = ''
`${getApiBase()}/requests/${issueSelectedMedia.requestId}/actions/replace`, if (issueOptions.can_act === false) {
{ actionMessage = 'Support has been given the selected title and affected content.'
method: 'POST', } else if (issueCategory === 'missing_content') {
headers: { 'Content-Type': 'application/json' }, actionMessage = await runIssueAction(`${actionBase}/search-missing`, {
body: JSON.stringify({
file_id: selectedReplacementFile.id,
confirmed: true,
issue_id: item?.id ?? null, issue_id: item?.id ?? null,
}), episode_ids: selectedMissingEpisodeIds,
} season_numbers: selectedSeasonNumbers,
) })
if (replacementResponse.ok) { } else if (issueCategory === 'subtitle') {
const replacementPayload = await replacementResponse.json() actionMessage = await runIssueAction(`${actionBase}/repair-subtitles`, {
completionMessage = `${completionMessage} ${replacementPayload?.message ?? 'Replacement search started.'}` issue_id: item?.id ?? null,
} else { episode_ids: isMovie ? [] : selectedEpisodeIds,
let replacementDetail = 'The replacement action could not be started.' forced: issueSymptoms.includes('Forced subtitles are missing'),
try { })
const replacementPayload = await replacementResponse.json() } else if (issueSupportsReplacement) {
if (typeof replacementPayload?.detail === 'string') replacementDetail = replacementPayload.detail actionMessage = await runIssueAction(`${actionBase}/replace`, {
} catch { issue_id: item?.id ?? null,
// Keep the user-safe fallback message. file_ids: actionFileIds,
} confirmed: true,
replacementFailure = `The issue was saved, but ${replacementDetail}` })
}
} }
if (actionMessage) completionMessage = `${completionMessage} ${actionMessage}`
setStatus(completionMessage) setStatus(completionMessage)
setError(replacementFailure) setError(null)
setIssueCategory(null) setIssueCategory(null)
setIssueMediaTitle('') setIssueMediaTitle('')
setIssueMediaType('movie') setIssueMediaType('movie')
setIssueEpisode('') setIssueSymptoms([])
setIssueScope('one_title') setIssueDevices([])
setIssueSymptom('')
setIssueDevice('')
setIssueNotes('')
setMediaServerStatus(null) setMediaServerStatus(null)
setIssueMediaQuery('') setIssueMediaQuery('')
setIssueMediaResults([]) setIssueMediaResults([])
setIssueSelectedMedia(null) setIssueSelectedMedia(null)
setReplacementFiles([]) setIssueOptions(null)
setReplacementFilesMessage(null) setIssueOptionsMessage(null)
setReplacementFileFilter('') setMovieTargetSelected(false)
setSelectedReplacementFileId(null) setActiveSeasonNumber(null)
setStartReplacement(false) setSelectedSeasonNumbers([])
setReplacementAllowed(true) setSelectedEpisodeIds([])
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()]) await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()])
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -1412,7 +1459,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<span className="issue-category-marker">{category.marker}</span> <span className="issue-category-marker">{category.marker}</span>
<strong>{category.label}</strong> <strong>{category.label}</strong>
<p>{category.description}</p> <p>{category.description}</p>
<small>{category.outcome}</small>
</button> </button>
))} ))}
</div> </div>
@@ -1428,37 +1474,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div> </div>
</div> </div>
{issueCategory === 'playback' ? (
<fieldset className="issue-choice-field">
<legend>How widespread is it?</legend>
<div className="issue-choice-row">
{[
['one_title', 'One title'],
['multiple_titles', 'Several titles'],
['everything', 'Nothing will play'],
].map(([value, label]) => (
<button
key={value}
type="button"
className={issueScope === value ? 'is-selected' : ''}
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}
</button>
))}
</div>
</fieldset>
) : null}
<div className="issue-question-grid"> <div className="issue-question-grid">
{issueNeedsMediaTitle ? ( {issueNeedsMediaTitle ? (
<div className="issue-media-finder issue-field-span-2"> <div className="issue-media-finder issue-field-span-2">
@@ -1472,9 +1487,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
if (issueSelectedMedia) { if (issueSelectedMedia) {
setIssueSelectedMedia(null) setIssueSelectedMedia(null)
setIssueMediaTitle('') setIssueMediaTitle('')
setReplacementFiles([]) setIssueOptions(null)
setSelectedReplacementFileId(null) setMovieTargetSelected(false)
setStartReplacement(false) setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
} }
}} }}
placeholder="Search the Grizzlyflix catalogue" placeholder="Search the Grizzlyflix catalogue"
@@ -1534,105 +1551,177 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div> </div>
) : null} ) : null}
{issueSelectedMedia && issueSupportsReplacement ? ( {issueOptionsLoading ? (
<div className="issue-file-picker"> <div className="issue-live-scan"><i /><span>Reading seasons and episodes</span></div>
<div>
<span className="section-kicker">Sonarr / Radarr files</span>
<h3>Select the exact file that is faulty</h3>
<p>The selected file is validated again before Magent allows replacement.</p>
</div>
{replacementFilesLoading ? (
<div className="issue-live-scan"><i /><span>Reading managed files from the collector</span></div>
) : null} ) : null}
{replacementFilesMessage ? <div className="status-banner">{replacementFilesMessage}</div> : null} {issueOptionsMessage && !issueOptions ? <div className="status-banner">{issueOptionsMessage}</div> : null}
{replacementFiles.length > 6 ? (
<input {issueOptions ? (
value={replacementFileFilter} <div className="issue-target-picker">
onChange={(event) => setReplacementFileFilter(event.target.value)} <fieldset className="issue-choice-field">
placeholder="Filter by season, episode, filename, or quality" <legend>What needs to be corrected?</legend>
/> <div className="issue-choice-grid">
) : null} {(issueOptions.request_type === 'movie' && issueCategory === 'missing_content'
{visibleReplacementFiles.length > 0 ? ( ? ['Entire title is missing']
<div className="issue-file-list"> : ISSUE_SYMPTOMS[issueCategory]).map((symptom) => {
{visibleReplacementFiles.map((file) => ( const selected = issueSymptoms.includes(symptom)
<label key={file.id} className={selectedReplacementFileId === file.id ? 'is-selected' : ''}> return (
<input <button
type="radio" key={symptom}
name="replacement-file" type="button"
checked={selectedReplacementFileId === file.id} className={selected ? 'is-selected' : ''}
onChange={() => { onClick={() => {
setSelectedReplacementFileId(file.id) if (symptom === 'Entire title is missing') {
setStartReplacement(false) setIssueSymptoms(selected ? [] : [symptom])
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
} else {
const withoutEntireTitle = issueSymptoms.filter((item) => item !== 'Entire title is missing')
const nextSymptoms = selected
? withoutEntireTitle.filter((item) => item !== symptom)
: [...withoutEntireTitle, symptom]
setIssueSymptoms(nextSymptoms)
if (symptom === 'Season is missing' && selected) {
setSelectedSeasonNumbers([])
}
if (
(symptom === 'Episode is missing' || symptom === 'Part or edition is missing') &&
selected &&
!nextSymptoms.includes('Episode is missing') &&
!nextSymptoms.includes('Part or edition is missing')
) {
setSelectedEpisodeIds([])
}
}
}} }}
/> >
<span> <span>{selected ? '✓' : '+'}</span>
<strong>{file.episodes?.length ? file.episodes.join(', ') : issueSelectedMedia.title}</strong> <strong>{issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom}</strong>
<small>{file.name}</small> </button>
</span> )
<b>{[file.quality, formatFileSize(file.size)].filter(Boolean).join(' · ')}</b> })}
</label>
))}
</div> </div>
) : null} </fieldset>
{selectedReplacementFile && replacementAllowed ? (
<label className="issue-replacement-toggle"> {issueSymptoms.length > 0 && issueOptions.request_type === 'movie' ? (
<input <button
type="checkbox" type="button"
checked={startReplacement} className={`issue-movie-target ${movieTargetSelected ? 'is-selected' : ''}`}
onChange={(event) => setStartReplacement(event.target.checked)} disabled={!movieTargetAvailable}
/> onClick={() => setMovieTargetSelected((current) => !current)}
<span> >
<strong>Replace this file after submitting the issue</strong> <span>{movieTargetSelected ? '✓' : 'MOVIE'}</span>
<div>
<strong>{issueOptions.title}</strong>
<small> <small>
Removes only this managed file, then immediately starts a replacement search through {issueMediaType === 'tv' ? 'Sonarr' : 'Radarr'}. {!movieTargetAvailable
? 'No managed file is available for this repair'
: issueOptions.movie?.best_fit
? 'This is the best fit'
: issueOptions.movie?.has_file
? 'Ready to select'
: 'Missing in Radarr'}
</small> </small>
</span> </div>
</label> </button>
) : null} ) : null}
{issueSymptoms.length > 0 && issueOptions.request_type === 'tv' && !missingEntireTitle ? (
<div className="issue-tv-targets">
<div className="issue-target-heading">
<strong>{missingSeasons ? 'Choose the missing seasons' : 'Choose a season'}</strong>
<small>You can select more than one.</small>
</div>
{missingSeasons ? (
<div className="issue-season-grid">
{issueOptions.seasons.map((season) => {
const selected = selectedSeasonNumbers.includes(season.season_number)
return (
<button
key={season.season_number}
type="button"
className={selected ? 'is-selected' : ''}
onClick={() => setSelectedSeasonNumbers((current) => current.includes(season.season_number)
? current.filter((value) => value !== season.season_number)
: [...current, season.season_number])}
>
<strong>{season.label}</strong>
<small>{season.missing_count} missing · {season.available_count} available</small>
{season.best_fit ? <b>This is the best fit</b> : null}
</button>
)
})}
</div> </div>
) : null} ) : null}
</div>
) : null} {(issueCategory !== 'missing_content' || missingEpisodes) ? (
{issueNeedsMediaTitle && issueMediaType === 'tv' ? ( <div className="issue-season-grid issue-season-tabs">
<label> {issueOptions.seasons.map((season) => (
<span>Season / episode</span> <button
<input key={season.season_number}
value={issueEpisode} type="button"
onChange={(event) => setIssueEpisode(event.target.value)} className={activeSeasonNumber === season.season_number ? 'is-selected' : ''}
placeholder="For example S02 E04" onClick={() => setActiveSeasonNumber(season.season_number)}
/> >
</label> <strong>{season.label}</strong>
) : null} <small>{season.episode_count} episodes</small>
<label className={issueNeedsMediaTitle && issueMediaType === 'tv' ? 'issue-field-span-2' : ''}> </button>
<span>What happens?</span>
<select value={issueSymptom} onChange={(event) => setIssueSymptom(event.target.value)}>
{ISSUE_SYMPTOMS[issueCategory].map((symptom) => (
<option key={symptom} value={symptom}>{symptom}</option>
))} ))}
</select> </div>
</label> ) : null}
{(issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<label> {(issueCategory !== 'missing_content' || missingEpisodes) && activeSeasonNumber !== null ? (
<span>Device or app</span> <div className="issue-episode-grid">
<input {issueOptions.episodes
value={issueDevice} .filter((episode) => episode.season_number === activeSeasonNumber && episode.released)
onChange={(event) => setIssueDevice(event.target.value)} .map((episode) => {
placeholder="For example Samsung TV or Chrome" const selected = selectedEpisodeIds.includes(episode.id)
/> const disabled = issueRequiresExistingFile && !episode.has_file
</label> return (
<button
key={episode.id}
type="button"
disabled={disabled}
className={selected ? 'is-selected' : ''}
onClick={() => setSelectedEpisodeIds((current) => current.includes(episode.id)
? current.filter((value) => value !== episode.id)
: [...current, episode.id])}
>
<span>{selected ? '✓' : episode.code}</span>
<strong>{episode.title}</strong>
<small>{episode.missing ? 'Missing in Sonarr' : episode.has_file ? 'Ready to select' : 'No file in Sonarr'}</small>
{episode.best_fit ? <b>This is the best fit</b> : null}
</button>
)
})}
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
) : null}
{issueTargetReady && (issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<fieldset className="issue-choice-field issue-field-span-2">
<legend>Where did it happen? <small>Choose all that apply</small></legend>
<div className="issue-choice-row">
{DEVICE_OPTIONS.map((device) => (
<button
key={device}
type="button"
className={issueDevices.includes(device) ? 'is-selected' : ''}
onClick={() => toggleStringChoice(device, issueDevices, setIssueDevices)}
>
{device}
</button>
))}
</div>
</fieldset>
) : null} ) : null}
<label className="issue-field-span-2">
<span>Anything else we should know?</span>
<textarea
rows={3}
value={issueNotes}
onChange={(event) => setIssueNotes(event.target.value)}
placeholder="Optional error message, timestamp, language, edition, or anything unusual"
/>
</label>
</div> </div>
{issueNeedsServerCheck ? ( {issueNeedsServerCheck && issueTargetReady ? (
<section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}> <section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}>
<div className="media-status-heading"> <div className="media-status-heading">
<div> <div>
@@ -1663,23 +1752,31 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</section> </section>
) : null} ) : null}
<section className="issue-resolution-card"> {issueTargetReady ? <section className="issue-resolution-card">
<span className="issue-step-number">03</span> <span className="issue-step-number">03</span>
<div> <div>
<span className="section-kicker">Recommended path</span> <span className="section-kicker">What will happen</span>
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2> <h2>
{issueOptions?.can_act === false
? 'The selected details will be sent to support.'
: selectedIssueDefinition.outcome}
</h2>
<p> <p>
{startReplacement && selectedReplacementFile {issueOptions?.can_act === false
? `After confirmation, ${selectedReplacementFile.name} will be removed and the collector will start searching immediately.` ? 'The issue and every selection will be sent to support. Automatic fixes are not enabled for this account.'
: issueNeedsServerCheck : issueCategory === 'subtitle'
? 'The live check above will be saved in the report, giving administrators immediate context.' ? 'The issue will be logged, then Bazarr will search for fresh subtitles for every selection.'
: 'Submit this report and it will arrive with the replacement or collection path already identified.'} : issueCategory === 'missing_content'
? 'The issue will be logged, then the selected movie, seasons, or episodes will be sent back to the collection pipeline.'
: issueSupportsReplacement
? 'The issue will be logged, then the selected content will be sent to Sonarr or Radarr for replacement.'
: 'The live Jellyfin check will be attached so support can see the server state immediately.'}
</p> </p>
</div> </div>
<button type="submit" disabled={creating || mediaServerChecking}> <button type="submit" disabled={creating || mediaServerChecking}>
{creating ? 'Working...' : startReplacement ? 'Submit issue & replace file' : 'Submit issue'} {creating ? 'Working...' : 'Submit and start fix'}
</button> </button>
</section> </section> : null}
</form> </form>
) : null} ) : null}
</section> </section>
+1
View File
@@ -19,6 +19,7 @@ const NAV_GROUPS = [
{ href: '/admin/jellyfin', label: 'Jellyfin' }, { href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' }, { href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' }, { href: '/admin/radarr', label: 'Radarr' },
{ href: '/admin/bazarr', label: 'Bazarr' },
{ href: '/admin/prowlarr', label: 'Prowlarr' }, { href: '/admin/prowlarr', label: 'Prowlarr' },
{ href: '/admin/qbittorrent', label: 'qBittorrent' }, { href: '/admin/qbittorrent', label: 'qBittorrent' },
], ],