Redesign the request portal flow
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user