Redesign the request portal flow
Magent CI/CD / verify (push) Canceled after 5m59s
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 14:53:15 +12:00
parent 963506d098
commit 9dfea25d56
6 changed files with 1084 additions and 11 deletions
+19 -7
View File
@@ -1,4 +1,5 @@
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
from .base import ApiClient
@@ -26,13 +27,15 @@ class JellyseerrClient(ApiClient):
return await self.get(f"/api/v1/tv/{tmdb_id}")
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/search",
params={
"query": query,
"page": page,
},
)
# Seerr rejects the `+` encoding that standard query builders use for
# spaces. Build this query explicitly so multi-word titles are sent as
# percent-encoded values.
encoded_query = quote(query, safe="")
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
async def get_service_settings(self, media_type: str) -> Optional[Any]:
service = "sonarr" if media_type == "tv" else "radarr"
return await self.get(f"/api/v1/settings/{service}")
async def create_request(
self,
@@ -41,6 +44,9 @@ class JellyseerrClient(ApiClient):
media_id: int,
seasons: Optional[list[int]] = None,
is_4k: Optional[bool] = None,
server_id: Optional[int] = None,
profile_id: Optional[int] = None,
root_folder: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {
"mediaType": media_type,
@@ -50,6 +56,12 @@ class JellyseerrClient(ApiClient):
payload["seasons"] = seasons
if isinstance(is_4k, bool):
payload["is4k"] = is_4k
if isinstance(server_id, int):
payload["serverId"] = server_id
if isinstance(profile_id, int):
payload["profileId"] = profile_id
if isinstance(root_folder, str) and root_folder.strip():
payload["rootFolder"] = root_folder.strip()
return await self.post("/api/v1/request", payload=payload)
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
+228 -1
View File
@@ -462,6 +462,123 @@ def _normalize_seasons(value: Any) -> list[int]:
return sorted(set(normalized))
def _normalize_request_profiles(value: Any) -> list[Dict[str, Any]]:
if not isinstance(value, list):
return []
profiles: list[Dict[str, Any]] = []
for item in value:
if not isinstance(item, dict):
continue
profile_id = _quality_profile_id(item.get("id"))
name = str(item.get("name") or "").strip()
if profile_id is None or not name:
continue
profiles.append({"id": profile_id, "name": name})
return profiles
def _normalize_request_roots(value: Any) -> list[str]:
if not isinstance(value, list):
return []
roots: list[str] = []
for item in value:
if not isinstance(item, dict):
continue
path = str(item.get("path") or "").strip()
if path:
roots.append(path)
return roots
def _normalize_seerr_servers(value: Any) -> list[Dict[str, Any]]:
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if isinstance(value, dict):
results = value.get("results")
if isinstance(results, list):
return [item for item in results if isinstance(item, dict)]
return []
async def _resolve_request_destination(
runtime: Any,
seerr: JellyseerrClient,
media_type: str,
requested_profile_id: Optional[int] = None,
) -> Dict[str, Any]:
if media_type == "tv":
collector_name = "Sonarr"
collector = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
configured_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
configured_root = str(runtime.sonarr_root_folder or "").strip()
else:
collector_name = "Radarr"
collector = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
configured_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
configured_root = str(runtime.radarr_root_folder or "").strip()
if not collector.configured():
raise HTTPException(status_code=400, detail=f"{collector_name} is not configured")
try:
server_settings, profile_payload, root_payload = await asyncio.gather(
seerr.get_service_settings(media_type),
collector.get_quality_profiles(),
collector.get_root_folders(),
)
except httpx.HTTPStatusError as exc:
service = "Seerr" if "/settings/" in str(exc.request.url) else collector_name
raise HTTPException(status_code=502, detail=_format_upstream_error(service, exc)) from exc
servers = [item for item in _normalize_seerr_servers(server_settings) if not item.get("is4k")]
if not servers:
raise HTTPException(
status_code=409,
detail=f"Seerr has no standard {collector_name} destination configured.",
)
server = next((item for item in servers if item.get("isDefault")), servers[0])
profiles = _normalize_request_profiles(profile_payload)
if not profiles:
raise HTTPException(status_code=409, detail=f"{collector_name} has no quality profiles available.")
profile_ids = {int(item["id"]) for item in profiles}
default_profile_id = _quality_profile_id(server.get("activeProfileId"))
if default_profile_id not in profile_ids:
default_profile_id = configured_profile_id if configured_profile_id in profile_ids else profiles[0]["id"]
selected_profile_id = requested_profile_id if requested_profile_id is not None else default_profile_id
if selected_profile_id not in profile_ids:
raise HTTPException(
status_code=400,
detail=f"The selected quality profile is not available in {collector_name}.",
)
roots = _normalize_request_roots(root_payload)
root_folder = str(server.get("activeDirectory") or "").strip()
if root_folder not in roots:
root_folder = configured_root if configured_root in roots else ""
if not root_folder:
raise HTTPException(
status_code=409,
detail=f"Seerr's {collector_name} library location does not match an active {collector_name} root folder.",
)
server_id = _quality_profile_id(server.get("id"))
if server_id is None:
raise HTTPException(status_code=409, detail=f"Seerr's {collector_name} destination is invalid.")
return {
"collector": collector_name,
"server_id": server_id,
"server_name": str(server.get("name") or collector_name),
"profile_id": int(selected_profile_id),
"default_profile_id": int(default_profile_id),
"profiles": profiles,
"root_folder": root_folder,
}
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
poster_path, backdrop_path = _extract_artwork_paths(payload)
tmdb_id, media_type = _extract_tmdb_lookup(payload)
@@ -1898,7 +2015,10 @@ async def recent_requests(
@router.get("/search")
async def search_requests(
query: str, page: int = 1, user: Dict[str, str] = Depends(get_current_user)
query: str,
page: int = 1,
media_type: Optional[str] = None,
user: Dict[str, str] = Depends(get_current_user),
) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
@@ -1918,11 +2038,17 @@ async def search_requests(
except httpx.HTTPStatusError:
pass
requested_media_type = _normalize_media_type(media_type) if media_type is not None else None
if media_type is not None and requested_media_type is None:
raise HTTPException(status_code=400, detail="media_type must be 'movie' or 'tv'")
results = []
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
for item in response.get("results", []):
media_type = item.get("mediaType")
if requested_media_type is not None and media_type != requested_media_type:
continue
title = item.get("title") or item.get("name")
year = None
if item.get("releaseDate"):
@@ -1980,6 +2106,7 @@ async def search_requests(
"statusLabel": status_label,
"requestedBy": requested_by,
"accessible": accessible,
"overview": item.get("overview"),
"posterPath": item.get("posterPath") or item.get("poster_path"),
"backdropPath": item.get("backdropPath") or item.get("backdrop_path"),
}
@@ -1988,6 +2115,84 @@ async def search_requests(
return {"results": results}
@router.get("/request-options")
async def request_options(
media_type: str,
tmdb_id: int,
user: Dict[str, str] = Depends(get_current_user),
) -> Dict[str, Any]:
del user
normalized_media_type = _normalize_media_type(media_type)
if normalized_media_type is None:
raise HTTPException(status_code=400, detail="media_type must be 'movie' or 'tv'")
if tmdb_id <= 0:
raise HTTPException(status_code=400, detail="tmdb_id must be a positive integer")
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Seerr not configured")
try:
details, destination = await asyncio.gather(
client.get_movie(tmdb_id) if normalized_media_type == "movie" else client.get_tv(tmdb_id),
_resolve_request_destination(runtime, client, normalized_media_type),
)
except HTTPException:
raise
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
if not isinstance(details, dict):
raise HTTPException(status_code=502, detail="Seerr returned invalid media details")
title = str(details.get("title") or details.get("name") or "Untitled")
date_value = details.get("releaseDate") or details.get("firstAirDate")
year = int(date_value[:4]) if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit() else None
seasons: list[Dict[str, Any]] = []
if normalized_media_type == "tv":
for season in details.get("seasons", []):
if not isinstance(season, dict):
continue
season_number = _quality_profile_id(season.get("seasonNumber"))
if season_number is None or season_number <= 0:
continue
seasons.append(
{
"seasonNumber": season_number,
"name": str(season.get("name") or f"Season {season_number}"),
"episodeCount": _quality_profile_id(season.get("episodeCount")) or 0,
"airDate": season.get("airDate"),
}
)
media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
requests_list = media_info.get("requests")
existing_request_id = None
if isinstance(requests_list, list) and requests_list and isinstance(requests_list[0], dict):
existing_request_id = _quality_profile_id(requests_list[0].get("id"))
return {
"media": {
"title": title,
"year": year,
"type": normalized_media_type,
"tmdbId": tmdb_id,
"overview": details.get("overview"),
"posterPath": details.get("posterPath") or details.get("poster_path"),
"backdropPath": details.get("backdropPath") or details.get("backdrop_path"),
"seasons": seasons,
"existingRequestId": existing_request_id,
},
"destination": {
"collector": destination["collector"],
"serverName": destination["server_name"],
"defaultProfileId": destination["default_profile_id"],
"profiles": destination["profiles"],
},
}
@router.post("/create")
async def create_request(
payload: Dict[str, Any], user: Dict[str, Any] = Depends(get_current_user)
@@ -2016,6 +2221,10 @@ async def create_request(
raise HTTPException(status_code=400, detail="tmdbId must be a positive integer")
seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else []
raw_profile_id = payload.get("profileId")
profile_id = _quality_profile_id(raw_profile_id) if raw_profile_id is not None else None
if raw_profile_id is not None and (profile_id is None or profile_id <= 0):
raise HTTPException(status_code=400, detail="profileId must be a positive integer")
raw_is_4k = payload.get("is4k")
if raw_is_4k is not None and not isinstance(raw_is_4k, bool):
raise HTTPException(status_code=400, detail="is4k must be true or false")
@@ -2065,12 +2274,30 @@ async def create_request(
"statusLabel": _status_label(existing_status),
}
if media_type == "tv" and seasons:
valid_seasons = {
_quality_profile_id(item.get("seasonNumber"))
for item in details.get("seasons", [])
if isinstance(item, dict)
}
invalid_seasons = [season for season in seasons if season not in valid_seasons]
if invalid_seasons:
raise HTTPException(
status_code=400,
detail=f"Season selection is not available for this series: {invalid_seasons}",
)
destination = await _resolve_request_destination(runtime, client, media_type, profile_id)
try:
created = await client.create_request(
media_type=media_type,
media_id=tmdb_id,
seasons=seasons if media_type == "tv" else None,
is_4k=is_4k,
server_id=destination["server_id"],
profile_id=destination["profile_id"],
root_folder=destination["root_folder"],
)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc
+115
View File
@@ -387,6 +387,121 @@ class RequestPresentationTests(unittest.TestCase):
self.assertEqual(available_stage["state"], "active")
class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
async def test_seerr_search_percent_encodes_multi_word_titles(self) -> None:
client = requests_router.JellyseerrClient("http://seerr.test", "key")
client.get = AsyncMock(return_value={"results": []})
await client.search("Ricky Gervais Alley Cats", page=2)
client.get.assert_awaited_once_with(
"/api/v1/search?query=Ricky%20Gervais%20Alley%20Cats&page=2"
)
async def test_seerr_request_includes_validated_destination_and_profile(self) -> None:
client = requests_router.JellyseerrClient("http://seerr.test", "key")
client.post = AsyncMock(return_value={"id": 42})
await client.create_request(
media_type="tv",
media_id=123,
seasons=[1, 2],
server_id=0,
profile_id=7,
root_folder="/TV98",
)
client.post.assert_awaited_once_with(
"/api/v1/request",
payload={
"mediaType": "tv",
"mediaId": 123,
"seasons": [1, 2],
"serverId": 0,
"profileId": 7,
"rootFolder": "/TV98",
},
)
async def test_request_destination_only_offers_live_sonarr_profiles(self) -> None:
runtime = SimpleNamespace(
sonarr_base_url="http://sonarr.test",
sonarr_api_key="key",
sonarr_quality_profile_id=7,
sonarr_root_folder="/tv",
)
seerr = SimpleNamespace(
get_service_settings=AsyncMock(
return_value=[
{
"id": 4,
"name": "Main Sonarr",
"isDefault": True,
"is4k": False,
"activeProfileId": 7,
"activeDirectory": "/tv",
}
]
)
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_quality_profiles=AsyncMock(
return_value=[{"id": 7, "name": "WEB-1080p"}, {"id": 10, "name": "Optimal"}]
),
get_root_folders=AsyncMock(return_value=[{"id": 1, "path": "/tv"}]),
)
with patch.object(requests_router, "SonarrClient", return_value=sonarr):
destination = await requests_router._resolve_request_destination(
runtime, seerr, "tv", requested_profile_id=10
)
self.assertEqual(destination["profile_id"], 10)
self.assertEqual(destination["default_profile_id"], 7)
self.assertEqual(destination["root_folder"], "/tv")
self.assertEqual(destination["profiles"], [
{"id": 7, "name": "WEB-1080p"},
{"id": 10, "name": "Optimal"},
])
async def test_request_destination_rejects_stale_profile_id(self) -> None:
runtime = SimpleNamespace(
radarr_base_url="http://radarr.test",
radarr_api_key="key",
radarr_quality_profile_id=6,
radarr_root_folder="/movies",
)
seerr = SimpleNamespace(
get_service_settings=AsyncMock(
return_value=[
{
"id": 2,
"name": "Main Radarr",
"isDefault": True,
"is4k": False,
"activeProfileId": 6,
"activeDirectory": "/movies",
}
]
)
)
radarr = SimpleNamespace(
configured=lambda: True,
get_quality_profiles=AsyncMock(return_value=[{"id": 6, "name": "HD"}]),
get_root_folders=AsyncMock(return_value=[{"id": 1, "path": "/movies"}]),
)
with patch.object(requests_router, "RadarrClient", return_value=radarr):
with self.assertRaises(HTTPException) as context:
await requests_router._resolve_request_destination(
runtime, seerr, "movie", requested_profile_id=999
)
self.assertEqual(context.exception.status_code, 400)
self.assertIn("not available in Radarr", context.exception.detail)
class RequestRecheckTests(unittest.IsolatedAsyncioTestCase):
async def test_recheck_refreshes_seerr_cache_and_returns_rebuilt_snapshot(self) -> None:
runtime = SimpleNamespace(