diff --git a/backend/app/clients/jellyseerr.py b/backend/app/clients/jellyseerr.py index 7201283..b83691b 100644 --- a/backend/app/clients/jellyseerr.py +++ b/backend/app/clients/jellyseerr.py @@ -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]]: diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 2e11773..ecf025f 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -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 diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 7a20788..3868d9b 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -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( diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index f6fedf4..29dd6d5 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -2212,3 +2212,229 @@ button:disabled, .request-operation-heading-actions { width: 100%; flex-wrap: wrap; } .request-operation-event { align-items: flex-start; } } + +/* Progressive request portal */ +.request-portal-page { + display: grid; + gap: 18px; + padding: clamp(14px, 2vw, 24px); +} + +.request-portal-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + min-height: 132px; + padding: clamp(20px, 3vw, 34px); + overflow: hidden; + border: 1px solid var(--ops-line); + border-radius: var(--ops-radius-lg); + background: + radial-gradient(circle at 12% 0%, rgba(126, 215, 255, 0.17), transparent 32%), + linear-gradient(120deg, rgba(79, 70, 229, 0.13), transparent 50%), + var(--ops-panel); +} +.request-portal-hero > div:first-child { display: grid; gap: 7px; max-width: 720px; } +.request-portal-hero h1 { font-size: clamp(1.75rem, 4vw, 3rem); letter-spacing: -0.045em; } +.request-portal-hero p { max-width: 650px; margin: 0; color: var(--ops-muted); line-height: 1.55; } +.request-portal-route { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; } +.request-portal-route span { + padding: 7px 10px; + border: 1px solid var(--ops-line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.035); + color: var(--ops-muted); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.66rem; + font-weight: 750; + text-transform: uppercase; +} +.request-portal-route i { width: 18px; height: 1px; background: linear-gradient(90deg, var(--ops-line), var(--ops-cyan)); } +.request-flow-alert { margin: 0; } + +.request-flow-stage { + position: relative; + display: grid; + gap: 20px; + padding: clamp(18px, 2.4vw, 28px); + border: 1px solid var(--ops-line-soft); + border-radius: var(--ops-radius-lg); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)); + animation: request-flow-arrive 0.34s ease both; +} +.request-flow-stage:not(:last-child)::after { + position: absolute; + bottom: -19px; + left: 44px; + width: 1px; + height: 19px; + background: linear-gradient(var(--ops-cyan), rgba(126, 215, 255, 0.15)); + content: ""; +} +.request-flow-stage.is-current { border-color: rgba(126, 215, 255, 0.24); } +@keyframes request-flow-arrive { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +.request-flow-heading { display: flex; align-items: center; gap: 13px; } +.request-flow-heading > div { display: grid; gap: 2px; } +.request-flow-heading > div > span, +.request-selection-summary small, +.request-existing-state span, +.request-submit-bar span, +.request-submit-progress header span { + color: var(--ops-cyan); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.66rem; + font-weight: 750; + letter-spacing: 0.055em; + text-transform: uppercase; +} +.request-flow-heading h2 { font-size: clamp(1.15rem, 2.2vw, 1.65rem); letter-spacing: -0.025em; } +.request-flow-number { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border: 1px solid rgba(126, 215, 255, 0.34); + border-radius: 50%; + background: rgba(14, 165, 233, 0.1); + color: var(--ops-cyan); + box-shadow: 0 0 22px rgba(14, 165, 233, 0.09); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.7rem; + font-weight: 800; +} + +.request-type-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.request-type-card { + display: grid; + justify-items: start; + gap: 8px; + min-height: 174px; + padding: 20px; + border: 1px solid var(--ops-line); + border-radius: var(--ops-radius-lg); + background: rgba(255, 255, 255, 0.025); + color: var(--ops-text); + text-align: left; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease; +} +.request-type-card:hover { transform: translateY(-2px); border-color: rgba(126, 215, 255, 0.5); } +.request-type-card.is-selected { border-color: rgba(72, 224, 178, 0.58); background: linear-gradient(135deg, rgba(72, 224, 178, 0.11), rgba(14, 165, 233, 0.06)); } +.request-type-card > span { color: var(--ops-cyan); font-family: "JetBrains Mono", Consolas, monospace; font-size: 0.66rem; text-transform: uppercase; } +.request-type-card strong { font-size: 1.35rem; } +.request-type-card p { max-width: 440px; margin: 0; color: var(--ops-muted); font-weight: 450; line-height: 1.5; } +.request-type-card b { margin-top: auto; color: var(--request-green); font-size: 0.74rem; } + +.request-flow-search { display: grid; gap: 7px; } +.request-flow-search > label, +.request-profile-field > span, +.request-season-picker legend { color: var(--ops-muted); font-size: 0.75rem; font-weight: 750; text-transform: uppercase; } +.request-flow-search > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 9px; } +.request-flow-search input { width: 100%; min-height: 48px; } +.request-flow-search button { min-width: 150px; } + +.request-flow-empty { display: grid; gap: 5px; padding: 20px; border: 1px dashed var(--ops-line); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.018); } +.request-flow-empty p { margin: 0; color: var(--ops-muted); } +.request-result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.request-result-card { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + gap: 15px; + min-width: 0; + min-height: 166px; + padding: 11px; + border: 1px solid var(--ops-line-soft); + border-radius: var(--ops-radius-lg); + background: rgba(255, 255, 255, 0.023); + color: var(--ops-text); + text-align: left; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease; +} +.request-result-card:hover { transform: translateY(-2px); border-color: rgba(126, 215, 255, 0.4); } +.request-result-card.is-selected { border-color: rgba(72, 224, 178, 0.55); background: rgba(72, 224, 178, 0.06); } +.request-result-poster, +.request-selection-poster { display: grid; place-items: center; overflow: hidden; border: 1px solid var(--ops-line); border-radius: calc(var(--ops-radius) - 2px); background: rgba(255, 255, 255, 0.035); } +.request-result-poster { width: 96px; height: 144px; } +.request-result-poster img, +.request-selection-poster img { width: 100%; height: 100%; object-fit: cover; } +.request-result-poster i, +.request-selection-poster i { color: var(--ops-muted); font-size: 0.65rem; font-style: normal; } +.request-result-copy { display: grid; align-content: start; gap: 6px; min-width: 0; padding: 5px 4px 5px 0; } +.request-result-copy small { color: var(--ops-cyan); font-size: 0.68rem; font-weight: 700; text-transform: uppercase; } +.request-result-copy strong { font-size: 1rem; } +.request-result-copy p { display: -webkit-box; margin: 0; overflow: hidden; color: var(--ops-muted); font-size: 0.76rem; font-weight: 450; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } +.request-result-copy b { align-self: end; margin-top: auto; color: var(--request-green); font-size: 0.71rem; } + +.request-configure-stage { border-color: rgba(72, 224, 178, 0.28); } +.request-selection-summary { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 16px; padding: 13px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius-lg); background: rgba(255, 255, 255, 0.022); } +.request-selection-poster { width: 86px; height: 129px; } +.request-selection-summary > div { display: grid; gap: 6px; } +.request-selection-summary h3 { font-size: clamp(1.15rem, 2vw, 1.55rem); } +.request-selection-summary p { max-width: 760px; margin: 0; color: var(--ops-muted); font-size: 0.82rem; line-height: 1.5; } +.request-existing-state { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 18px; border: 1px solid rgba(72, 224, 178, 0.28); border-radius: var(--ops-radius-lg); background: rgba(72, 224, 178, 0.06); } +.request-existing-state > div { display: grid; gap: 5px; } +.request-existing-state p { margin: 0; color: var(--ops-muted); } + +.request-options-layout { display: grid; gap: 16px; } +.request-season-picker { display: grid; gap: 11px; margin: 0; padding: 0; border: 0; } +.request-season-actions { display: flex; gap: 7px; } +.request-season-actions button { padding: 6px 9px; border-color: var(--ops-line); background: rgba(255, 255, 255, 0.025); color: var(--ops-muted); font-size: 0.67rem; } +.request-season-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.request-season-grid label { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 11px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.022); cursor: pointer; } +.request-season-grid label.is-selected { border-color: rgba(72, 224, 178, 0.4); background: rgba(72, 224, 178, 0.07); } +.request-season-grid label > span { display: grid; gap: 2px; min-width: 0; } +.request-season-grid label strong { overflow: hidden; font-size: 0.78rem; text-overflow: ellipsis; white-space: nowrap; } +.request-season-grid label small { color: var(--ops-muted); font-size: 0.67rem; } +.request-profile-field { display: grid; gap: 7px; max-width: 620px; } +.request-profile-field select { min-height: 46px; } +.request-profile-field small, +.request-submit-bar small { color: var(--ops-muted); font-size: 0.7rem; } +.request-submit-bar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 17px; border: 1px solid rgba(126, 215, 255, 0.28); border-radius: var(--ops-radius-lg); background: linear-gradient(110deg, rgba(14, 165, 233, 0.09), rgba(79, 70, 229, 0.05)); } +.request-submit-bar > div { display: grid; gap: 3px; } +.request-submit-bar button { min-width: 180px; } + +.request-submit-progress { display: grid; gap: 10px; padding: 14px; border: 1px solid rgba(126, 215, 255, 0.3); border-radius: var(--ops-radius-lg); background: rgba(14, 165, 233, 0.055); } +.request-submit-progress.is-complete { border-color: rgba(72, 224, 178, 0.3); background: rgba(72, 224, 178, 0.045); } +.request-submit-progress.is-error { border-color: rgba(255, 86, 113, 0.35); background: rgba(255, 86, 113, 0.05); } +.request-submit-progress header { display: flex; align-items: center; justify-content: space-between; gap: 14px; } +.request-submit-progress header > div { display: grid; gap: 3px; } +.request-submit-progress header small { color: var(--ops-muted); } +.request-submit-progress > div { display: grid; gap: 6px; } +.request-submit-progress p { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; margin: 0; padding: 9px 10px; border: 1px solid var(--ops-line-soft); border-radius: var(--ops-radius); background: rgba(255, 255, 255, 0.02); } +.request-submit-progress p > i { width: 8px; height: 8px; border-radius: 50%; background: var(--ops-muted); } +.request-submit-progress p.is-active > i { background: var(--ops-cyan); box-shadow: 0 0 12px var(--ops-cyan); animation: request-operation-pulse 1.15s ease-in-out infinite; } +.request-submit-progress p.is-complete > i { background: var(--request-green); } +.request-submit-progress p.is-error > i { background: var(--request-red); } +.request-submit-progress p > span { display: grid; gap: 1px; color: var(--ops-muted); font-size: 0.72rem; } +.request-submit-progress p > span strong { color: var(--ops-text); font-size: 0.7rem; } +.request-submit-progress p > small { color: var(--ops-muted); font-size: 0.65rem; } +.request-complete-actions { display: flex; gap: 9px; justify-content: flex-end; } + +@media (max-width: 920px) { + .request-portal-hero { align-items: flex-start; flex-direction: column; } + .request-result-grid { grid-template-columns: 1fr; } + .request-season-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 640px) { + .request-portal-page { padding: 10px; } + .request-portal-route { width: 100%; overflow-x: auto; } + .request-type-grid, + .request-season-grid { grid-template-columns: 1fr; } + .request-flow-search > div { grid-template-columns: 1fr; } + .request-flow-search button { width: 100%; } + .request-result-card { grid-template-columns: 76px minmax(0, 1fr); } + .request-result-poster { width: 76px; height: 114px; } + .request-selection-summary { grid-template-columns: 66px minmax(0, 1fr); } + .request-selection-poster { width: 66px; height: 99px; } + .request-existing-state, + .request-submit-bar { align-items: stretch; flex-direction: column; } + .request-existing-state button, + .request-submit-bar button { width: 100%; } + .request-submit-progress p { grid-template-columns: auto minmax(0, 1fr); } + .request-submit-progress p > small { grid-column: 2; } + .request-complete-actions { display: grid; } +} diff --git a/frontend/app/portal/requests/RequestPortalClient.tsx b/frontend/app/portal/requests/RequestPortalClient.tsx new file mode 100644 index 0000000..6ee357c --- /dev/null +++ b/frontend/app/portal/requests/RequestPortalClient.tsx @@ -0,0 +1,494 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' + +type MediaType = 'movie' | 'tv' + +type DiscoveryResult = { + title: string + year?: number | null + type: MediaType + tmdbId: number + requestId?: number | null + statusLabel?: string | null + overview?: string | null + posterPath?: string | null + backdropPath?: string | null +} + +type RequestOptions = { + media: DiscoveryResult & { + seasons: Array<{ + seasonNumber: number + name: string + episodeCount: number + airDate?: string | null + }> + existingRequestId?: number | null + } + destination: { + collector: 'Sonarr' | 'Radarr' + serverName: string + defaultProfileId: number + profiles: Array<{ id: number; name: string }> + } +} + +type OperationEvent = { + id: string + service: string + state: 'active' | 'complete' | 'error' + message: string + duration_ms?: number | null + status_code?: number | null +} + +type OperationProgress = { + status: 'running' | 'complete' | 'error' + duration_ms?: number | null + events: OperationEvent[] +} + +const mediaChoices: Array<{ + type: MediaType + eyebrow: string + title: string + description: string +}> = [ + { + type: 'movie', + eyebrow: 'Film', + title: 'Movie', + description: 'Find a film and send it through Seerr to Radarr.', + }, + { + type: 'tv', + eyebrow: 'Series', + title: 'TV show', + description: 'Choose a series, the seasons you want, and send it to Sonarr.', + }, +] + +const artworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w342') => { + if (!path) return null + return `https://image.tmdb.org/t/p/${size}${path.startsWith('/') ? path : `/${path}`}` +} + +const apiError = async (response: Response, fallback: string) => { + try { + const payload = await response.json() + if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail + if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message + } catch { + // The upstream response was not JSON. Use the friendly fallback below. + } + return fallback +} + +const formatDuration = (milliseconds?: number | null) => { + if (milliseconds == null) return null + if (milliseconds < 1000) return `${Math.round(milliseconds)} ms` + return `${(milliseconds / 1000).toFixed(1)} s` +} + +export default function RequestPortalClient() { + const router = useRouter() + const searchSectionRef = useRef(null) + const resultsSectionRef = useRef(null) + const configureSectionRef = useRef(null) + const [mediaType, setMediaType] = useState(null) + const [query, setQuery] = useState('') + const [searching, setSearching] = useState(false) + const [searchAttempted, setSearchAttempted] = useState(false) + const [results, setResults] = useState([]) + const [selected, setSelected] = useState(null) + const [options, setOptions] = useState(null) + const [loadingOptions, setLoadingOptions] = useState(false) + const [profileId, setProfileId] = useState(null) + const [selectedSeasons, setSelectedSeasons] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [operation, setOperation] = useState(null) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + useEffect(() => { + if (!getToken()) router.push('/login') + }, [router]) + + const resetAfterType = (nextType: MediaType) => { + setMediaType(nextType) + setQuery('') + setResults([]) + setSearchAttempted(false) + setSelected(null) + setOptions(null) + setProfileId(null) + setSelectedSeasons([]) + setOperation(null) + setError(null) + setSuccess(null) + window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80) + } + + const runSearch = async (event: React.FormEvent) => { + event.preventDefault() + if (!mediaType) return + const term = query.trim() + if (!term) { + setError('Enter a title to search for.') + return + } + setSearching(true) + setSearchAttempted(true) + setSelected(null) + setOptions(null) + setOperation(null) + setError(null) + setSuccess(null) + try { + const baseUrl = getApiBase() + const params = new URLSearchParams({ query: term, media_type: mediaType }) + const response = await authFetch(`${baseUrl}/requests/search?${params.toString()}`) + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + if (!response.ok) throw new Error(await apiError(response, `Search failed (${response.status}).`)) + const payload = await response.json() + const mapped: DiscoveryResult[] = Array.isArray(payload?.results) + ? payload.results + .filter((item: any) => item?.type === mediaType && Number(item?.tmdbId) > 0) + .map((item: any) => ({ + title: String(item?.title || 'Untitled'), + year: typeof item?.year === 'number' ? item.year : null, + type: mediaType, + tmdbId: Number(item.tmdbId), + requestId: typeof item?.requestId === 'number' ? item.requestId : null, + statusLabel: typeof item?.statusLabel === 'string' ? item.statusLabel : null, + overview: typeof item?.overview === 'string' ? item.overview : null, + posterPath: item?.posterPath ?? null, + backdropPath: item?.backdropPath ?? null, + })) + : [] + setResults(mapped) + window.setTimeout(() => resultsSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80) + } catch (caught) { + setResults([]) + setError(caught instanceof Error ? caught.message : 'Search is unavailable right now.') + } finally { + setSearching(false) + } + } + + const selectResult = async (item: DiscoveryResult) => { + setSelected(item) + setOptions(null) + setProfileId(null) + setSelectedSeasons([]) + setOperation(null) + setError(null) + setSuccess(null) + if (item.requestId) { + window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80) + return + } + + setLoadingOptions(true) + try { + const baseUrl = getApiBase() + const params = new URLSearchParams({ media_type: item.type, tmdb_id: String(item.tmdbId) }) + const response = await authFetch(`${baseUrl}/requests/request-options?${params.toString()}`) + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + if (!response.ok) throw new Error(await apiError(response, `Could not load request options (${response.status}).`)) + const payload = (await response.json()) as RequestOptions + const refreshedSelection: DiscoveryResult = { + ...item, + title: payload.media.title || item.title, + year: payload.media.year ?? item.year, + overview: payload.media.overview || item.overview, + posterPath: payload.media.posterPath || item.posterPath, + backdropPath: payload.media.backdropPath || item.backdropPath, + requestId: payload.media.existingRequestId || item.requestId, + statusLabel: payload.media.existingRequestId ? 'Already requested' : item.statusLabel, + } + setSelected(refreshedSelection) + if (payload.media.existingRequestId) { + setResults((current) => current.map((result) => result.tmdbId === item.tmdbId && result.type === item.type + ? refreshedSelection + : result)) + return + } + setOptions(payload) + setProfileId(payload.destination.defaultProfileId) + setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber)) + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Could not load request options.') + } finally { + setLoadingOptions(false) + window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80) + } + } + + const pollOperation = async (operationId: string) => { + try { + const response = await authFetch(`${getApiBase()}/operations/${operationId}`) + if (response.ok) setOperation((await response.json()) as OperationProgress) + } catch { + // The request response remains authoritative if a progress poll is interrupted. + } + } + + const submitRequest = async () => { + if (!selected || !options || !profileId) return + if (selected.type === 'tv' && selectedSeasons.length === 0) { + setError('Select at least one season.') + return + } + setSubmitting(true) + setError(null) + setSuccess(null) + const operationId = globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}` + setOperation({ status: 'running', events: [] }) + const interval = window.setInterval(() => void pollOperation(operationId), 500) + try { + const response = await authFetch(`${getApiBase()}/requests/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Magent-Operation-ID': operationId, + 'X-Magent-Operation-Label': `Requesting ${selected.title}`, + }, + body: JSON.stringify({ + mediaType: selected.type, + tmdbId: selected.tmdbId, + profileId, + seasons: selected.type === 'tv' ? selectedSeasons : undefined, + }), + }) + await pollOperation(operationId) + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + if (!response.ok) throw new Error(await apiError(response, `Request failed (${response.status}).`)) + const payload = await response.json() + const requestId = typeof payload?.requestId === 'number' ? payload.requestId : null + setSelected((current) => current ? { ...current, requestId, statusLabel: payload?.statusLabel ?? current.statusLabel } : current) + setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type + ? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel } + : item)) + setSuccess(requestId ? `Request #${requestId} has been accepted by Seerr.` : 'Your request has been accepted by Seerr.') + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'The request could not be submitted.') + } finally { + window.clearInterval(interval) + await pollOperation(operationId) + setSubmitting(false) + } + } + + const setEverySeason = (checked: boolean) => { + setSelectedSeasons(checked && options ? options.media.seasons.map((season) => season.seasonNumber) : []) + } + + const selectedPoster = artworkUrl(selected?.posterPath, 'w185') + + return ( +
+
+
+ Request portal +

Find something worth watching.

+

Choose what you want, find the right title, then tailor the request before it goes to Seerr.

+
+
+ Seerr