Streamline issue reporting and automate repairs
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const ALLOWED_SECTIONS = new Set([
|
|||||||
'artwork',
|
'artwork',
|
||||||
'sonarr',
|
'sonarr',
|
||||||
'radarr',
|
'radarr',
|
||||||
|
'bazarr',
|
||||||
'prowlarr',
|
'prowlarr',
|
||||||
'qbittorrent',
|
'qbittorrent',
|
||||||
'requests',
|
'requests',
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user