diff --git a/.build_number b/.build_number index cc43b70..49f3e6b 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0803262038 +0803262216 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index cd9cb7b..8affb41 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0803262038" -CHANGELOG = '2026-03-07|Process 1 build 0703261729\n2026-03-04|Process 1 build 0403261902\n2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' +BUILD_NUMBER = "0803262216" +CHANGELOG = '2026-03-08|Process 1 build 0803262038\n2026-03-07|Process 1 build 0703261729\n2026-03-04|Process 1 build 0403261902\n2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' diff --git a/backend/app/clients/jellyseerr.py b/backend/app/clients/jellyseerr.py index 23314d6..7201283 100644 --- a/backend/app/clients/jellyseerr.py +++ b/backend/app/clients/jellyseerr.py @@ -34,6 +34,24 @@ class JellyseerrClient(ApiClient): }, ) + async def create_request( + self, + *, + media_type: str, + media_id: int, + seasons: Optional[list[int]] = None, + is_4k: Optional[bool] = None, + ) -> Optional[Dict[str, Any]]: + payload: Dict[str, Any] = { + "mediaType": media_type, + "mediaId": media_id, + } + if isinstance(seasons, list) and seasons: + payload["seasons"] = seasons + if isinstance(is_4k, bool): + payload["is4k"] = is_4k + return await self.post("/api/v1/request", payload=payload) + async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]: return await self.get( "/api/v1/user", diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 2f7ad1e..bc0a8b0 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -421,6 +421,34 @@ def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Option return tmdb_id, media_type +def _normalize_media_type(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + normalized = value.strip().lower() + if normalized in {"movie", "tv"}: + return normalized + return None + + +def _normalize_seasons(value: Any) -> list[int]: + if value is None: + return [] + if not isinstance(value, list): + raise HTTPException(status_code=400, detail="seasons must be an array of positive integers") + normalized: list[int] = [] + for raw in value: + try: + season = int(raw) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=400, detail="seasons must contain only positive integers" + ) from exc + if season <= 0: + raise HTTPException(status_code=400, detail="seasons must contain only positive integers") + normalized.append(season) + return sorted(set(normalized)) + + 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) @@ -1864,12 +1892,135 @@ async def search_requests( "statusLabel": status_label, "requestedBy": requested_by, "accessible": accessible, + "posterPath": item.get("posterPath") or item.get("poster_path"), + "backdropPath": item.get("backdropPath") or item.get("backdrop_path"), } ) return {"results": results} +@router.post("/create") +async def create_request( + payload: Dict[str, Any], user: Dict[str, Any] = Depends(get_current_user) +) -> Dict[str, Any]: + 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") + + media_type = _normalize_media_type( + payload.get("mediaType") or payload.get("type") or payload.get("media_type") + ) + if media_type is None: + raise HTTPException(status_code=400, detail="mediaType must be 'movie' or 'tv'") + + raw_tmdb_id = payload.get("tmdbId") + if raw_tmdb_id is None: + raw_tmdb_id = payload.get("mediaId") + if raw_tmdb_id is None: + raw_tmdb_id = payload.get("id") + try: + tmdb_id = int(raw_tmdb_id) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="tmdbId must be a valid integer") from exc + if tmdb_id <= 0: + raise HTTPException(status_code=400, detail="tmdbId must be a positive integer") + + seasons = _normalize_seasons(payload.get("seasons")) if media_type == "tv" else [] + 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") + is_4k = raw_is_4k if isinstance(raw_is_4k, bool) else None + + try: + details = await (client.get_movie(tmdb_id) if media_type == "movie" else client.get_tv(tmdb_id)) + 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="Invalid response from Seerr media lookup") + + media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {} + requests_list = media_info.get("requests") + existing_request: Optional[Dict[str, Any]] = None + if isinstance(requests_list, list) and requests_list: + first_request = requests_list[0] + if isinstance(first_request, dict): + existing_request = first_request + + title = details.get("title") or details.get("name") + year: Optional[int] = None + date_value = details.get("releaseDate") or details.get("firstAirDate") + if isinstance(date_value, str) and len(date_value) >= 4 and date_value[:4].isdigit(): + year = int(date_value[:4]) + + if isinstance(existing_request, dict): + existing_request_id = _quality_profile_id(existing_request.get("id")) + existing_status = existing_request.get("status") + if existing_request_id is not None: + request_payload = await _get_request_details(client, existing_request_id) + if isinstance(request_payload, dict): + parsed_payload = _parse_request_payload(request_payload) + upsert_request_cache(**_build_request_cache_record(parsed_payload, request_payload)) + _cache_set(f"request:{existing_request_id}", request_payload) + title = parsed_payload.get("title") or title + year = parsed_payload.get("year") or year + return { + "status": "exists", + "requestId": existing_request_id, + "type": media_type, + "tmdbId": tmdb_id, + "title": title, + "year": year, + "statusCode": existing_status, + "statusLabel": _status_label(existing_status), + } + + 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, + ) + except httpx.HTTPStatusError as exc: + raise HTTPException(status_code=502, detail=_format_upstream_error("Seerr", exc)) from exc + + if not isinstance(created, dict): + raise HTTPException(status_code=502, detail="Invalid response from Seerr request create") + + parsed = _parse_request_payload(created) + request_id = _quality_profile_id(parsed.get("request_id")) + status_code = parsed.get("status") + title = parsed.get("title") or title + year = parsed.get("year") or year + + if request_id is not None: + upsert_request_cache(**_build_request_cache_record(parsed, created)) + _cache_set(f"request:{request_id}", created) + _recent_cache["updated_at"] = None + await asyncio.to_thread( + save_action, + str(request_id), + "request_created", + "Create request", + "ok", + f"{media_type} request created from discovery by {user.get('username')}.", + ) + + return { + "status": "created", + "requestId": request_id, + "type": media_type, + "tmdbId": tmdb_id, + "title": title, + "year": year, + "statusCode": status_code, + "statusLabel": _status_label(status_code), + } + + @router.post("/{request_id}/ai/triage", response_model=TriageResult) async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult: runtime = get_runtime_settings() diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 18a3753..0680cb9 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -6597,6 +6597,86 @@ textarea { gap: 12px; } +.portal-discovery-panel { + display: grid; + gap: 12px; +} + +.portal-discovery-form { + display: grid; + grid-template-columns: minmax(0, 1fr) 140px; + gap: 10px; +} + +.portal-discovery-form input { + width: 100%; +} + +.portal-discovery-results { + display: grid; + gap: 10px; +} + +.portal-discovery-item { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel-soft); + padding: 10px; +} + +.portal-discovery-media { + width: 56px; + height: 84px; + border-radius: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--line); +} + +.portal-discovery-media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.portal-discovery-main { + display: grid; + gap: 6px; +} + +.portal-discovery-title-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.portal-discovery-main p { + margin: 0; + font-size: 0.84rem; + color: var(--muted); +} + +.portal-discovery-actions { + display: flex; + align-items: center; +} + +.poster-fallback { + display: grid; + place-items: center; + width: 100%; + height: 100%; + color: var(--muted); + font-size: 0.66rem; + text-align: center; + padding: 4px; +} + .portal-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -6756,6 +6836,15 @@ textarea { .portal-item-list { max-height: 460px; } + + .portal-discovery-item { + grid-template-columns: 56px minmax(0, 1fr); + } + + .portal-discovery-actions { + grid-column: span 2; + justify-content: flex-end; + } } @media (max-width: 760px) { @@ -6776,4 +6865,22 @@ textarea { .portal-mine-toggle { grid-column: span 1; } + + .portal-discovery-form { + grid-template-columns: 1fr; + } + + .portal-discovery-item { + grid-template-columns: 1fr; + } + + .portal-discovery-media { + width: 72px; + height: 108px; + } + + .portal-discovery-actions { + grid-column: span 1; + justify-content: flex-start; + } } diff --git a/frontend/app/portal/page.tsx b/frontend/app/portal/page.tsx index ba7f391..fc02650 100644 --- a/frontend/app/portal/page.tsx +++ b/frontend/app/portal/page.tsx @@ -67,6 +67,19 @@ type UserProfile = { role: string } +type DiscoveryResult = { + title: string + year?: number | null + type?: 'movie' | 'tv' | null + tmdbId?: number | null + requestId?: number | null + statusLabel?: string | null + status?: number | null + accessible?: boolean + posterPath?: string | null + backdropPath?: string | null +} + const KIND_OPTIONS = [ { value: 'request', label: 'Request' }, { value: 'issue', label: 'Issue' }, @@ -176,6 +189,11 @@ export default function PortalPage() { const [commentText, setCommentText] = useState('') const [commentInternal, setCommentInternal] = useState(false) const [preselectedItemId, setPreselectedItemId] = useState(null) + const [discoverQuery, setDiscoverQuery] = useState('') + const [discoverLoading, setDiscoverLoading] = useState(false) + const [discoverResults, setDiscoverResults] = useState([]) + const [discoverError, setDiscoverError] = useState(null) + const [requestingTmdbIds, setRequestingTmdbIds] = useState>({}) const isAdmin = me?.role === 'admin' @@ -306,6 +324,123 @@ export default function PortalPage() { } } + const resolveTmdbArtworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w185') => { + if (!path) return null + const normalized = path.startsWith('/') ? path : `/${path}` + return `https://image.tmdb.org/t/p/${size}${normalized}` + } + + const runDiscoverySearch = async (event?: React.FormEvent) => { + if (event) event.preventDefault() + const query = discoverQuery.trim() + if (!query) { + setDiscoverResults([]) + setDiscoverError('Enter a title to search.') + return + } + setDiscoverLoading(true) + setDiscoverError(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(query)}`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || `Search failed (${response.status})`) + } + const data = await response.json() + const mapped: DiscoveryResult[] = Array.isArray(data?.results) + ? data.results.map((item: any) => ({ + title: item?.title ?? 'Untitled', + year: typeof item?.year === 'number' ? item.year : null, + type: item?.type === 'movie' || item?.type === 'tv' ? item.type : null, + 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, + })) + : [] + setDiscoverResults(mapped) + } catch (err) { + console.error(err) + setDiscoverResults([]) + setDiscoverError(err instanceof Error ? err.message : 'Search failed.') + } finally { + setDiscoverLoading(false) + } + } + + const requestDiscoveryItem = async (item: DiscoveryResult) => { + if (!item.tmdbId || !item.type) { + setError('Could not request this result because required media details are missing.') + return + } + const key = `${item.type}:${item.tmdbId}` + setRequestingTmdbIds((prev) => ({ ...prev, [key]: true })) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/requests/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mediaType: item.type, + tmdbId: item.tmdbId, + }), + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || `Request failed (${response.status})`) + } + const data = await response.json() + const requestId = typeof data?.requestId === 'number' ? data.requestId : null + const statusLabel = typeof data?.statusLabel === 'string' ? data.statusLabel : item.statusLabel + const statusCode = typeof data?.statusCode === 'number' ? data.statusCode : item.status + setDiscoverResults((prev) => + prev.map((entry) => + entry.tmdbId === item.tmdbId && entry.type === item.type + ? { + ...entry, + requestId, + statusLabel, + status: statusCode, + accessible: true, + } + : entry + ) + ) + if (requestId) { + const mode = data?.status === 'exists' ? 'already exists' : 'created' + setStatus(`Request ${mode}. Open request #${requestId} for the full pipeline.`) + } else { + setStatus('Request submitted.') + } + await Promise.all([loadItems(), loadOverview()]) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not create request.') + } finally { + setRequestingTmdbIds((prev) => { + const next = { ...prev } + delete next[key] + return next + }) + } + } + useEffect(() => { if (!getToken()) { router.push('/login') @@ -522,6 +657,85 @@ export default function PortalPage() { {error &&
{error}
} {status &&
{status}
} +
+
+
+

Search and request content

+

+ Search Seerr content directly, then submit a request in one click. +

+
+
+
+ setDiscoverQuery(event.target.value)} + placeholder="Search movies or TV shows" + /> + +
+ {discoverError &&
{discoverError}
} +
+ {discoverLoading ? ( +
Searching Seerr…
+ ) : discoverResults.length === 0 ? ( +
No discovery results yet.
+ ) : ( + discoverResults.map((item, index) => { + const key = `${item.type ?? 'unknown'}:${item.tmdbId ?? index}` + const requesting = Boolean(requestingTmdbIds[key]) + const poster = resolveTmdbArtworkUrl(item.posterPath, 'w185') + const hasRequest = typeof item.requestId === 'number' && item.requestId > 0 + return ( +
+
+ {poster ? :
No artwork
} +
+
+
+ {item.title || 'Untitled'} + {item.type ?? 'unknown'} + {item.year ? {item.year} : null} +
+

+ {hasRequest ? ( + <> + Already requested + {item.statusLabel ? ` · ${item.statusLabel}` : ''} + {item.requestId ? ` · #${item.requestId}` : ''} + + ) : ( + 'Not requested yet' + )} +

+
+
+ {hasRequest ? ( + + ) : ( + + )} +
+
+ ) + }) + )} +
+
+
Total items diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 72b9ebc..e3bab30 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "magent-frontend", - "version": "0803262038", + "version": "0803262216", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "magent-frontend", - "version": "0803262038", + "version": "0803262216", "dependencies": { "next": "16.1.6", "react": "19.2.4", diff --git a/frontend/package.json b/frontend/package.json index 8f7d6bb..24d071e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "magent-frontend", "private": true, - "version": "0803262038", + "version": "0803262216", "scripts": { "dev": "next dev", "build": "next build",