Add exact media replacement from issues
Magent CI/CD / verify (push) Canceled after 6m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-08-31 22:05:11 +12:00
parent f8770cb44a
commit ec8145a58a
7 changed files with 979 additions and 34 deletions
+6 -2
View File
@@ -183,5 +183,9 @@ class ApiClient:
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]: async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("PUT", path, payload=payload) return await self._request("PUT", path, payload=payload)
async def delete(self, path: str) -> Optional[Any]: async def delete(
return await self._request("DELETE", path) self,
path: str,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Any]:
return await self._request("DELETE", path, params=params)
+6
View File
@@ -39,6 +39,12 @@ class RadarrClient(ApiClient):
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]: async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]}) return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/moviefile/{movie_file_id}",
params={"deleteFromClient": "true"},
)
async def add_movie( async def add_movie(
self, self,
tmdb_id: int, tmdb_id: int,
+9
View File
@@ -41,6 +41,9 @@ class SonarrClient(ApiClient):
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]: async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id}) return await self.get("/api/v3/episode", params={"seriesId": series_id})
async def get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]: async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
return await self.get( return await self.get(
"/api/v3/release", "/api/v3/release",
@@ -54,6 +57,12 @@ class SonarrClient(ApiClient):
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]: async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids}) return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/episodefile/{episode_file_id}",
params={"deleteFromClient": "true"},
)
async def add_series( async def add_series(
self, self,
tvdb_id: int, tvdb_id: int,
+241
View File
@@ -1758,6 +1758,44 @@ def _missing_episode_ids_by_season(episodes: Any) -> Dict[int, List[int]]:
return grouped return grouped
def _replacement_file_name(file_data: Dict[str, Any]) -> str:
raw = file_data.get("relativePath") or file_data.get("path") or file_data.get("sceneName")
if isinstance(raw, str) and raw.strip():
return raw.strip().replace("\\", "/").rsplit("/", 1)[-1]
return "Managed media file"
def _replacement_quality_name(file_data: Dict[str, Any]) -> Optional[str]:
quality = file_data.get("quality")
if not isinstance(quality, dict):
return None
nested = quality.get("quality")
if isinstance(nested, dict):
value = nested.get("name")
return str(value).strip() if value is not None and str(value).strip() else None
value = quality.get("name")
return str(value).strip() if value is not None and str(value).strip() else None
def _replacement_file_payload(
file_data: Dict[str, Any],
*,
episode_numbers: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
file_id = file_data.get("id")
if not isinstance(file_id, int) or file_id <= 0:
return None
size = file_data.get("size")
return {
"id": file_id,
"name": _replacement_file_name(file_data),
"quality": _replacement_quality_name(file_data),
"size": int(size) if isinstance(size, (int, float)) and size >= 0 else None,
"season_number": file_data.get("seasonNumber") if isinstance(file_data.get("seasonNumber"), int) else None,
"episodes": episode_numbers or [],
}
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str: async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
if root_folder.isdigit(): if root_folder.isdigit():
folders = await client.get_root_folders() folders = await client.get_root_folders()
@@ -1771,6 +1809,209 @@ async def _resolve_root_folder_path(client: Any, root_folder: str, service_name:
return root_folder return root_folder
@router.get("/{request_id}/replacement-options")
async def replacement_options(
request_id: str,
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict):
return {
"request_id": request_id,
"request_type": snapshot.request_type.value,
"title": snapshot.title,
"files": [],
"message": "This title is not currently linked to a Sonarr/Radarr library item.",
}
files: List[Dict[str, Any]] = []
if snapshot.request_type == RequestType.movie:
movie_file = arr_item.get("movieFile")
if isinstance(movie_file, dict):
option = _replacement_file_payload(movie_file)
if option:
files.append(option)
elif snapshot.request_type == RequestType.tv:
series_id = arr_item.get("id")
if not isinstance(series_id, int):
raise HTTPException(status_code=502, detail="Sonarr returned an invalid series record")
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not sonarr.configured():
raise HTTPException(status_code=400, detail="Sonarr is not configured")
try:
episode_files, episodes = await asyncio.gather(
sonarr.get_episode_files(series_id),
sonarr.get_episodes(series_id),
)
except Exception as exc:
logger.warning("Sonarr replacement options failed request_id=%s error=%s", request_id, exc)
raise HTTPException(
status_code=502,
detail="Magent could not read the managed episode files from Sonarr.",
) from exc
episode_labels: Dict[int, List[str]] = {}
if isinstance(episodes, list):
for episode in episodes:
if not isinstance(episode, dict):
continue
file_id = episode.get("episodeFileId")
season_number = episode.get("seasonNumber")
episode_number = episode.get("episodeNumber")
if not all(isinstance(value, int) for value in (file_id, season_number, episode_number)):
continue
label = f"S{season_number:02d}E{episode_number:02d}"
episode_labels.setdefault(file_id, []).append(label)
if isinstance(episode_files, list):
for file_data in episode_files:
if not isinstance(file_data, dict):
continue
option = _replacement_file_payload(
file_data,
episode_numbers=episode_labels.get(file_data.get("id"), []),
)
if option:
files.append(option)
files.sort(
key=lambda item: (
item.get("season_number") if isinstance(item.get("season_number"), int) else 9999,
",".join(item.get("episodes") or []),
str(item.get("name") or ""),
)
)
return {
"request_id": request_id,
"request_type": snapshot.request_type.value,
"title": snapshot.title,
"files": files,
"can_replace": _user_can_use_search_auto(user),
"message": (
"Choose the exact managed file to remove and replace."
if files
else "Sonarr/Radarr does not currently report a managed file for this title."
),
}
@router.post("/{request_id}/actions/replace")
async def action_replace_media(
request_id: str,
payload: Dict[str, Any],
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
if not request_id.isdigit():
raise HTTPException(status_code=400, detail="Invalid request id")
if not _user_can_use_search_auto(user):
raise HTTPException(status_code=403, detail="Media replacement is disabled for this user")
if payload.get("confirmed") is not True:
raise HTTPException(status_code=400, detail="Replacement confirmation is required")
file_id = payload.get("file_id")
if not isinstance(file_id, int) or file_id <= 0:
raise HTTPException(status_code=400, detail="A valid managed file is required")
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
snapshot = await build_snapshot(request_id)
arr_item = snapshot.raw.get("arr", {}).get("item")
if not isinstance(arr_item, dict):
raise HTTPException(status_code=404, detail="Item not found in Sonarr/Radarr")
collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
target_name = "managed media file"
try:
if snapshot.request_type == RequestType.movie:
movie_id = arr_item.get("id")
movie_file = arr_item.get("movieFile")
if not isinstance(movie_id, int) or not isinstance(movie_file, dict):
raise HTTPException(status_code=409, detail="Radarr does not report a replaceable movie file")
if movie_file.get("id") != file_id:
raise HTTPException(status_code=409, detail="The selected movie file is no longer current")
target_name = _replacement_file_name(movie_file)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not radarr.configured():
raise HTTPException(status_code=400, detail="Radarr is not configured")
await radarr.delete_movie_file(file_id)
await radarr.search(movie_id)
elif snapshot.request_type == RequestType.tv:
series_id = arr_item.get("id")
if not isinstance(series_id, int):
raise HTTPException(status_code=502, detail="Sonarr returned an invalid series record")
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not sonarr.configured():
raise HTTPException(status_code=400, detail="Sonarr is not configured")
episode_files, episodes = await asyncio.gather(
sonarr.get_episode_files(series_id),
sonarr.get_episodes(series_id),
)
selected_file = next(
(
item
for item in episode_files
if isinstance(item, dict) and item.get("id") == file_id
),
None,
) if isinstance(episode_files, list) else None
if not isinstance(selected_file, dict):
raise HTTPException(status_code=409, detail="The selected episode file is no longer current")
episode_ids = [
episode.get("id")
for episode in episodes
if isinstance(episode, dict)
and episode.get("episodeFileId") == file_id
and isinstance(episode.get("id"), int)
] if isinstance(episodes, list) else []
if not episode_ids:
raise HTTPException(status_code=409, detail="Sonarr could not match episodes to this file")
target_name = _replacement_file_name(selected_file)
await sonarr.delete_episode_file(file_id)
await sonarr.search_episodes(episode_ids)
else:
raise HTTPException(status_code=400, detail="Unknown request type")
except HTTPException:
raise
except Exception as exc:
logger.exception("%s media replacement failed request_id=%s file_id=%s", collector, request_id, file_id)
detail = (
f"{collector} could not complete the replacement. Check the request action history "
"before trying again."
)
await asyncio.to_thread(
save_action,
request_id,
"replace_media",
"Replace media file",
"failed",
detail,
)
raise HTTPException(status_code=502, detail=detail) from exc
message = f"{collector} removed {target_name} and started a replacement search."
await asyncio.to_thread(
save_action,
request_id,
"replace_media",
"Replace media file",
"ok",
message,
)
return {
"status": "ok",
"message": message,
"collector": collector,
"request_id": request_id,
"file_id": file_id,
}
@router.get("/{request_id}/snapshot", response_model=Snapshot) @router.get("/{request_id}/snapshot", response_model=Snapshot)
async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot: async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot:
runtime = get_runtime_settings() runtime = get_runtime_settings()
+91
View File
@@ -1166,6 +1166,97 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
) )
class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
async def test_movie_replacement_validates_file_then_deletes_and_searches(self) -> None:
snapshot = Snapshot(
request_id="3914",
title="Replacement Movie",
request_type=RequestType.movie,
state=NormalizedState.available,
raw={
"arr": {
"item": {
"id": 44,
"movieFile": {
"id": 77,
"relativePath": "Replacement.Movie.1080p.mkv",
},
}
}
},
)
radarr = SimpleNamespace(
configured=lambda: True,
delete_movie_file=AsyncMock(return_value=None),
search=AsyncMock(return_value={"id": 1}),
)
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr",
jellyseerr_api_key="secret",
radarr_base_url="http://radarr",
radarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "RadarrClient", return_value=radarr),
patch.object(requests_router, "save_action"),
):
result = await requests_router.action_replace_media(
"3914",
{"file_id": 77, "confirmed": True},
{"username": "admin", "role": "admin", "auto_search_enabled": True},
)
self.assertEqual(result["status"], "ok")
radarr.delete_movie_file.assert_awaited_once_with(77)
radarr.search.assert_awaited_once_with(44)
async def test_tv_replacement_options_return_only_safe_file_details(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Replacement Series",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 22}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episode_files=AsyncMock(return_value=[{
"id": 88,
"seasonNumber": 2,
"path": "/private/library/Replacement.Series.S02E03.mkv",
"size": 1024,
"quality": {"quality": {"name": "WEBDL-1080p"}},
}]),
get_episodes=AsyncMock(return_value=[{
"id": 101,
"episodeFileId": 88,
"seasonNumber": 2,
"episodeNumber": 3,
}]),
)
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr",
jellyseerr_api_key="secret",
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
):
result = await requests_router.replacement_options(
"3909",
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["files"][0]["name"], "Replacement.Series.S02E03.mkv")
self.assertEqual(result["files"][0]["episodes"], ["S02E03"])
self.assertNotIn("/private/library", str(result))
class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase): class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None: def setUp(self) -> None:
portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None) portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None)
+220
View File
@@ -2743,6 +2743,204 @@ button:disabled,
grid-column: span 2; grid-column: span 2;
} }
.issue-media-finder {
display: grid;
gap: 11px;
padding: 14px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius-lg);
background: rgba(255, 255, 255, 0.018);
}
.issue-media-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.issue-media-search-row button {
min-width: 120px;
}
.issue-media-results {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
max-height: 410px;
overflow: auto;
padding-right: 2px;
}
.issue-media-result {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-width: 0;
padding: 8px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.022);
color: var(--ops-text);
text-align: left;
}
.issue-media-result:hover {
border-color: rgba(126, 215, 255, 0.42);
background: rgba(14, 165, 233, 0.07);
}
.issue-media-result > span:last-child {
display: grid;
gap: 3px;
min-width: 0;
}
.issue-media-result small,
.issue-media-result b,
.issue-selected-media small {
color: var(--ops-muted);
font-size: 0.64rem;
font-weight: 600;
}
.issue-media-result b {
color: var(--request-green);
}
.issue-media-poster {
display: grid;
place-items: center;
width: 54px;
height: 80px;
overflow: hidden;
border: 1px solid var(--ops-line-soft);
border-radius: calc(var(--ops-radius) - 2px);
background: rgba(255, 255, 255, 0.025);
}
.issue-media-poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.issue-media-poster i {
color: var(--ops-muted);
font-size: 0.58rem;
font-style: normal;
text-align: center;
}
.issue-selected-media {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
padding: 13px;
border: 1px solid rgba(72, 224, 178, 0.34);
border-radius: var(--ops-radius);
background: rgba(72, 224, 178, 0.055);
}
.issue-selected-media > div {
display: grid;
gap: 3px;
}
.issue-file-picker {
display: grid;
gap: 11px;
padding: 14px;
border: 1px solid rgba(126, 215, 255, 0.25);
border-radius: var(--ops-radius);
background: rgba(14, 165, 233, 0.035);
}
.issue-file-picker > div:first-child {
display: grid;
gap: 3px;
}
.issue-file-picker h3,
.issue-file-picker p {
margin: 0;
}
.issue-file-picker p {
color: var(--ops-muted);
font-size: 0.72rem;
}
.issue-file-list {
display: grid;
gap: 7px;
max-height: 340px;
overflow: auto;
padding-right: 2px;
}
.issue-file-list > label {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-width: 0;
padding: 10px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.022);
cursor: pointer;
}
.issue-file-list > label.is-selected {
border-color: rgba(72, 224, 178, 0.45);
background: rgba(72, 224, 178, 0.055);
}
.issue-file-list > label > span {
display: grid;
gap: 2px;
min-width: 0;
}
.issue-file-list strong,
.issue-file-list small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.issue-file-list small,
.issue-file-list b {
color: var(--ops-muted);
font-size: 0.65rem;
font-weight: 600;
}
.issue-replacement-toggle {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 11px;
padding: 13px;
border: 1px solid rgba(255, 192, 84, 0.36);
border-radius: var(--ops-radius);
background: rgba(255, 192, 84, 0.055);
cursor: pointer;
}
.issue-replacement-toggle > span {
display: grid;
gap: 3px;
}
.issue-replacement-toggle small {
color: var(--ops-muted);
font-size: 0.69rem;
line-height: 1.45;
}
.media-status-check { .media-status-check {
display: grid; display: grid;
gap: 13px; gap: 13px;
@@ -2859,6 +3057,22 @@ button:disabled,
font-size: 0.65rem; font-size: 0.65rem;
} }
.issue-linked-request {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px;
border: 1px solid rgba(126, 215, 255, 0.28);
border-radius: var(--ops-radius);
background: rgba(14, 165, 233, 0.045);
}
.issue-linked-request > div {
display: grid;
gap: 3px;
}
.issue-portal-page .portal-item-row p { .issue-portal-page .portal-item-row p {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
@@ -2880,9 +3094,15 @@ button:disabled,
.issue-flow { padding: 14px; } .issue-flow { padding: 14px; }
.issue-category-grid, .issue-category-grid,
.issue-question-grid, .issue-question-grid,
.issue-media-results,
.media-status-metrics { grid-template-columns: 1fr; } .media-status-metrics { grid-template-columns: 1fr; }
.issue-field-span-2 { grid-column: span 1; } .issue-field-span-2 { grid-column: span 1; }
.issue-category-card { min-height: 0; } .issue-category-card { min-height: 0; }
.issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); } .issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); }
.issue-resolution-card > button { grid-column: 1 / -1; width: 100%; } .issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
.issue-media-search-row { grid-template-columns: 1fr; }
.issue-selected-media { align-items: stretch; flex-direction: column; }
.issue-linked-request { align-items: stretch; flex-direction: column; }
.issue-file-list > label { grid-template-columns: auto minmax(0, 1fr); }
.issue-file-list > label > b { grid-column: 2; }
} }
+399 -25
View File
@@ -105,6 +105,15 @@ type MediaServerStatus = {
} }
} }
type ReplacementFile = {
id: number
name: string
quality?: string | null
size?: number | null
season_number?: number | null
episodes?: string[]
}
const ISSUE_CATEGORIES: Array<{ const ISSUE_CATEGORIES: Array<{
id: IssueCategoryId id: IssueCategoryId
marker: string marker: string
@@ -265,6 +274,13 @@ const formatDate = (value?: string | null) => {
return parsed.toLocaleString() return parsed.toLocaleString()
} }
const formatFileSize = (value?: number | null) => {
if (!value || value <= 0) return 'Size unavailable'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
return `${(value / 1024 ** unitIndex).toFixed(unitIndex >= 3 ? 1 : 0)} ${units[unitIndex]}`
}
const toPositiveInt = (value: string) => { const toPositiveInt = (value: string) => {
const parsed = Number.parseInt(value, 10) const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed) || parsed <= 0) return null if (Number.isNaN(parsed) || parsed <= 0) return null
@@ -338,6 +354,17 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null) const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
const [mediaServerChecking, setMediaServerChecking] = useState(false) const [mediaServerChecking, setMediaServerChecking] = useState(false)
const [mediaServerError, setMediaServerError] = useState<string | null>(null) const [mediaServerError, setMediaServerError] = useState<string | null>(null)
const [issueMediaQuery, setIssueMediaQuery] = useState('')
const [issueMediaSearching, setIssueMediaSearching] = useState(false)
const [issueMediaResults, setIssueMediaResults] = useState<DiscoveryResult[]>([])
const [issueSelectedMedia, setIssueSelectedMedia] = useState<DiscoveryResult | null>(null)
const [replacementFiles, setReplacementFiles] = useState<ReplacementFile[]>([])
const [replacementFilesLoading, setReplacementFilesLoading] = useState(false)
const [replacementFilesMessage, setReplacementFilesMessage] = useState<string | null>(null)
const [replacementFileFilter, setReplacementFileFilter] = useState('')
const [selectedReplacementFileId, setSelectedReplacementFileId] = useState<number | null>(null)
const [startReplacement, setStartReplacement] = useState(false)
const [replacementAllowed, setReplacementAllowed] = useState(true)
const isAdmin = me?.role === 'admin' const isAdmin = me?.role === 'admin'
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0) const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
@@ -348,7 +375,21 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const issueNeedsMediaTitle = const issueNeedsMediaTitle =
Boolean(issueCategory) && Boolean(issueCategory) &&
issueCategory !== 'service_unavailable' && issueCategory !== 'service_unavailable' &&
!(issueCategory === 'playback' && issueScope === 'everything') !(issueCategory === 'playback' && issueScope !== 'one_title')
const issueSupportsReplacement =
issueCategory === 'broken_media' ||
issueCategory === 'audio' ||
issueCategory === 'subtitle' ||
(issueCategory === 'playback' && issueScope === 'one_title')
const selectedReplacementFile =
replacementFiles.find((file) => file.id === selectedReplacementFileId) ?? null
const visibleReplacementFiles = replacementFiles.filter((file) => {
const query = replacementFileFilter.trim().toLowerCase()
if (!query) return true
return [file.name, file.quality, ...(file.episodes ?? [])]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query))
})
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
@@ -594,6 +635,129 @@ export default function PortalClient({ workspace }: PortalClientProps) {
} }
} }
const loadReplacementFiles = async (media: DiscoveryResult) => {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
setReplacementFileFilter('')
if (!media.requestId) {
setReplacementFilesMessage(
'This title is not linked to an existing Magent request, so there is no managed file to replace.'
)
return
}
setReplacementFilesLoading(true)
setReplacementFilesMessage(null)
try {
const response = await authFetch(
`${getApiBase()}/requests/${media.requestId}/replacement-options`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Could not load the managed files from Sonarr/Radarr.')
}
const payload = await response.json()
const files = Array.isArray(payload?.files) ? payload.files as ReplacementFile[] : []
setReplacementFiles(files)
setReplacementAllowed(payload?.can_replace !== false)
setReplacementFilesMessage(
payload?.can_replace === false
? 'Your account can report this file, but automatic replacement is disabled.'
: payload?.message ?? null
)
if (files.length === 1 && payload?.can_replace !== false) {
setSelectedReplacementFileId(files[0].id)
}
} catch (err) {
console.error(err)
setReplacementFilesMessage(
err instanceof Error ? err.message : 'Could not load the managed files from Sonarr/Radarr.'
)
} finally {
setReplacementFilesLoading(false)
}
}
const searchIssueMedia = async (event?: React.FormEvent) => {
event?.preventDefault()
const query = issueMediaQuery.trim()
if (!query) {
setError('Enter the movie or TV show you are having trouble with.')
return
}
setIssueMediaSearching(true)
setError(null)
setIssueMediaResults([])
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
try {
const response = await authFetch(
`${getApiBase()}/requests/search?query=${encodeURIComponent(query)}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('Magent could not search the media catalogue.')
}
const payload = await response.json()
const results: DiscoveryResult[] = Array.isArray(payload?.results)
? payload.results
.filter((item: any) => item?.type === 'movie' || item?.type === 'tv')
.map((item: any) => ({
title: item?.title ?? 'Untitled',
year: typeof item?.year === 'number' ? item.year : null,
type: item.type,
tmdbId: typeof item?.tmdbId === 'number' ? item.tmdbId : null,
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
statusLabel: item?.statusLabel ?? null,
status: typeof item?.status === 'number' ? item.status : null,
accessible: Boolean(item?.accessible),
posterPath: item?.posterPath ?? null,
backdropPath: item?.backdropPath ?? null,
}))
: []
setIssueMediaResults(results.slice(0, 12))
if (results.length === 0) {
setError('No matching movie or TV show was found. Try the title without a year.')
}
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Magent could not search the media catalogue.')
} finally {
setIssueMediaSearching(false)
}
}
const selectIssueMedia = (media: DiscoveryResult) => {
setIssueSelectedMedia(media)
setIssueMediaTitle(media.title)
setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie')
setIssueMediaResults([])
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`)
setError(null)
if (issueSupportsReplacement) {
void loadReplacementFiles(media)
} else {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementFilesMessage(null)
}
}
const checkMediaServer = async () => { const checkMediaServer = async () => {
setMediaServerChecking(true) setMediaServerChecking(true)
setMediaServerError(null) setMediaServerError(null)
@@ -628,6 +792,21 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setMediaServerError(null) setMediaServerError(null)
setError(null) setError(null)
setStatus(null) setStatus(null)
const supportsReplacement = ['broken_media', 'audio', 'subtitle', 'playback'].includes(category)
if (category === 'service_unavailable') {
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setIssueMediaQuery('')
setIssueMediaResults([])
}
if (supportsReplacement && issueSelectedMedia?.requestId) {
void loadReplacementFiles(issueSelectedMedia)
} else if (!supportsReplacement) {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementFilesMessage(null)
}
if (category === 'playback' || category === 'service_unavailable') { if (category === 'playback' || category === 'service_unavailable') {
void checkMediaServer() void checkMediaServer()
} }
@@ -640,8 +819,27 @@ export default function PortalClient({ workspace }: PortalClientProps) {
return return
} }
const cleanMediaTitle = issueMediaTitle.trim() const cleanMediaTitle = issueMediaTitle.trim()
if (issueNeedsMediaTitle && !cleanMediaTitle) { if (issueNeedsMediaTitle && (!cleanMediaTitle || !issueSelectedMedia)) {
setError('Enter the affected movie or TV show so the file can be identified.') setError('Search for and select the exact movie or TV show so the managed file can be identified.')
return
}
if (startReplacement && (!issueSelectedMedia?.requestId || !selectedReplacementFile)) {
setError('Select the exact Sonarr/Radarr file before starting a replacement.')
return
}
if (startReplacement && !replacementAllowed) {
setError('Automatic replacement is disabled for this account. The issue can still be submitted.')
return
}
if (
startReplacement &&
selectedReplacementFile &&
!window.confirm(
`Replace “${selectedReplacementFile.name}”?\n\n` +
'The current managed file will be removed and Sonarr/Radarr will immediately search for a replacement. ' +
'The title may be unavailable until the new download is imported.'
)
) {
return return
} }
@@ -683,6 +881,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
`Scope: ${scopeLabel}`, `Scope: ${scopeLabel}`,
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null, cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null, cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null,
issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null,
selectedReplacementFile ? `Managed file: ${selectedReplacementFile.name}` : null,
startReplacement ? 'Replacement action: confirmed by the reporting user' : null,
issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null, issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null,
issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null, issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null,
...diagnosticLines, ...diagnosticLines,
@@ -709,6 +910,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
description, description,
issue_type: resolvedIssueType, issue_type: resolvedIssueType,
media_type: cleanMediaTitle ? issueMediaType : null, media_type: cleanMediaTitle ? issueMediaType : null,
external_ref: issueSelectedMedia?.requestId
? `/requests/${issueSelectedMedia.requestId}`
: null,
priority, priority,
}), }),
}) })
@@ -723,11 +927,39 @@ export default function PortalClient({ workspace }: PortalClientProps) {
} }
const data = await response.json() const data = await response.json()
const item = data?.item as PortalItem | undefined const item = data?.item as PortalItem | undefined
setStatus( let completionMessage = item?.id
item?.id
? `Issue #${item.id} submitted with the troubleshooting details.` ? `Issue #${item.id} submitted with the troubleshooting details.`
: 'Issue submitted with the troubleshooting details.' : 'Issue submitted with the troubleshooting details.'
let replacementFailure: string | null = null
if (startReplacement && issueSelectedMedia?.requestId && selectedReplacementFile) {
const replacementResponse = await authFetch(
`${getApiBase()}/requests/${issueSelectedMedia.requestId}/actions/replace`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_id: selectedReplacementFile.id,
confirmed: true,
issue_id: item?.id ?? null,
}),
}
) )
if (replacementResponse.ok) {
const replacementPayload = await replacementResponse.json()
completionMessage = `${completionMessage} ${replacementPayload?.message ?? 'Replacement search started.'}`
} else {
let replacementDetail = 'The replacement action could not be started.'
try {
const replacementPayload = await replacementResponse.json()
if (typeof replacementPayload?.detail === 'string') replacementDetail = replacementPayload.detail
} catch {
// Keep the user-safe fallback message.
}
replacementFailure = `The issue was saved, but ${replacementDetail}`
}
}
setStatus(completionMessage)
setError(replacementFailure)
setIssueCategory(null) setIssueCategory(null)
setIssueMediaTitle('') setIssueMediaTitle('')
setIssueMediaType('movie') setIssueMediaType('movie')
@@ -737,6 +969,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueDevice('') setIssueDevice('')
setIssueNotes('') setIssueNotes('')
setMediaServerStatus(null) setMediaServerStatus(null)
setIssueMediaQuery('')
setIssueMediaResults([])
setIssueSelectedMedia(null)
setReplacementFiles([])
setReplacementFilesMessage(null)
setReplacementFileFilter('')
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()]) await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()])
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -1123,7 +1364,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
key={value} key={value}
type="button" type="button"
className={issueScope === value ? 'is-selected' : ''} className={issueScope === value ? 'is-selected' : ''}
onClick={() => setIssueScope(value as typeof issueScope)} onClick={() => {
const nextScope = value as typeof issueScope
setIssueScope(nextScope)
if (nextScope !== 'one_title') {
setStartReplacement(false)
setSelectedReplacementFileId(null)
} else if (issueSelectedMedia?.requestId) {
void loadReplacementFiles(issueSelectedMedia)
}
}}
> >
{label} {label}
</button> </button>
@@ -1134,27 +1384,137 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<div className="issue-question-grid"> <div className="issue-question-grid">
{issueNeedsMediaTitle ? ( {issueNeedsMediaTitle ? (
<label className="issue-field-span-2"> <div className="issue-media-finder issue-field-span-2">
<span>Affected movie or TV show</span> <label>
<span>Find the exact movie or TV show</span>
<div className="issue-media-search-row">
<input <input
required value={issueMediaQuery}
value={issueMediaTitle} onChange={(event) => {
onChange={(event) => setIssueMediaTitle(event.target.value)} setIssueMediaQuery(event.target.value)
placeholder="Start typing the exact title" if (issueSelectedMedia) {
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
}
}}
placeholder="Search the Grizzlyflix catalogue"
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void searchIssueMedia()
}
}}
/> />
<button type="button" onClick={() => void searchIssueMedia()} disabled={issueMediaSearching}>
{issueMediaSearching ? 'Searching...' : 'Search'}
</button>
</div>
</label>
{issueMediaResults.length > 0 ? (
<div className="issue-media-results">
{issueMediaResults.map((media, index) => {
const poster = resolveTmdbArtworkUrl(media.posterPath, 'w185')
return (
<button
key={`${media.type}:${media.tmdbId ?? index}`}
type="button"
className="issue-media-result"
onClick={() => selectIssueMedia(media)}
>
<span className="issue-media-poster">
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
</span>
<span>
<small>{media.type === 'tv' ? 'TV show' : 'Movie'}{media.year ? ` · ${media.year}` : ''}</small>
<strong>{media.title}</strong>
<b>{media.requestId ? media.statusLabel || `Request #${media.requestId}` : 'Not currently requested'}</b>
</span>
</button>
)
})}
</div>
) : null}
{issueSelectedMedia ? (
<div className="issue-selected-media">
<div>
<span className="section-kicker">Selected title</span>
<strong>{issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''}</strong>
<small>
{issueSelectedMedia.type === 'tv' ? 'TV show' : 'Movie'}
{issueSelectedMedia.requestId
? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? 'Tracked'}`
: ' · No existing Magent request'}
</small>
</div>
{issueSelectedMedia.requestId ? (
<button type="button" className="ghost-button" onClick={() => router.push(`/requests/${issueSelectedMedia.requestId}`)}>
Open request
</button>
) : null}
</div>
) : null}
{issueSelectedMedia && issueSupportsReplacement ? (
<div className="issue-file-picker">
<div>
<span className="section-kicker">Sonarr / Radarr files</span>
<h3>Select the exact file that is faulty</h3>
<p>The selected file is validated again before Magent allows replacement.</p>
</div>
{replacementFilesLoading ? (
<div className="issue-live-scan"><i /><span>Reading managed files from the collector</span></div>
) : null}
{replacementFilesMessage ? <div className="status-banner">{replacementFilesMessage}</div> : null}
{replacementFiles.length > 6 ? (
<input
value={replacementFileFilter}
onChange={(event) => setReplacementFileFilter(event.target.value)}
placeholder="Filter by season, episode, filename, or quality"
/>
) : null}
{visibleReplacementFiles.length > 0 ? (
<div className="issue-file-list">
{visibleReplacementFiles.map((file) => (
<label key={file.id} className={selectedReplacementFileId === file.id ? 'is-selected' : ''}>
<input
type="radio"
name="replacement-file"
checked={selectedReplacementFileId === file.id}
onChange={() => {
setSelectedReplacementFileId(file.id)
setStartReplacement(false)
}}
/>
<span>
<strong>{file.episodes?.length ? file.episodes.join(', ') : issueSelectedMedia.title}</strong>
<small>{file.name}</small>
</span>
<b>{[file.quality, formatFileSize(file.size)].filter(Boolean).join(' · ')}</b>
</label>
))}
</div>
) : null}
{selectedReplacementFile && replacementAllowed ? (
<label className="issue-replacement-toggle">
<input
type="checkbox"
checked={startReplacement}
onChange={(event) => setStartReplacement(event.target.checked)}
/>
<span>
<strong>Replace this file after submitting the issue</strong>
<small>
Removes only this managed file, then immediately starts a replacement search through {issueMediaType === 'tv' ? 'Sonarr' : 'Radarr'}.
</small>
</span>
</label> </label>
) : null} ) : null}
{issueNeedsMediaTitle ? ( </div>
<label> ) : null}
<span>Media type</span> </div>
<select
value={issueMediaType}
onChange={(event) => setIssueMediaType(event.target.value as 'movie' | 'tv')}
>
<option value="movie">Movie</option>
<option value="tv">TV show</option>
</select>
</label>
) : null} ) : null}
{issueNeedsMediaTitle && issueMediaType === 'tv' ? ( {issueNeedsMediaTitle && issueMediaType === 'tv' ? (
<label> <label>
@@ -1232,13 +1592,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<span className="section-kicker">Recommended path</span> <span className="section-kicker">Recommended path</span>
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2> <h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2>
<p> <p>
{issueNeedsServerCheck {startReplacement && selectedReplacementFile
? `After confirmation, ${selectedReplacementFile.name} will be removed and the collector will start searching immediately.`
: issueNeedsServerCheck
? 'The live check above will be saved in the report, giving administrators immediate context.' ? 'The live check above will be saved in the report, giving administrators immediate context.'
: 'Submit this report and it will arrive with the replacement or collection path already identified.'} : 'Submit this report and it will arrive with the replacement or collection path already identified.'}
</p> </p>
</div> </div>
<button type="submit" disabled={creating || mediaServerChecking}> <button type="submit" disabled={creating || mediaServerChecking}>
{creating ? 'Submitting...' : 'Submit issue'} {creating ? 'Working...' : startReplacement ? 'Submit issue & replace file' : 'Submit issue'}
</button> </button>
</section> </section>
</form> </form>
@@ -1502,6 +1864,18 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div> </div>
</div> </div>
{selectedItem.kind === 'issue' && selectedItem.external_ref?.startsWith('/requests/') ? (
<div className="issue-linked-request">
<div>
<span className="section-kicker">Linked collection record</span>
<strong>{selectedItem.external_ref.replace('/requests/', 'Request #')}</strong>
</div>
<button type="button" onClick={() => router.push(selectedItem.external_ref as string)}>
Open request
</button>
</div>
) : null}
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}> <form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
<label className="portal-field-span-2"> <label className="portal-field-span-2">
<span>Title</span> <span>Title</span>