From 45c295457a2851fd49eaea30e082b477c0f35641 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 13 Jul 2026 09:58:25 +0000 Subject: [PATCH] fix(jellyseer): resolve request titles via /movie|tv endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The requests table showed all names as "—" because Jellyseerr's /api/v1/request list does NOT embed titles — they live on the Movie/Series records. Added JellyseerrClient._resolve_title(media_type, tmdb_id) that fetches /api/v1/movie/{tmdbId} (→ title) or /api/v1/tv/{tmdbId} (→ name), cached on the client instance so subsequent polls are instant. Also scoped the table fetch to open requests only (pending + approved) via Jellyseerr's filter param, instead of fetching all 800+ historical requests. open_requests() fetches pending+approved (paginated), resolves their titles (small set → fast), and returns them sorted by date added desc. Updated the frontend table's status filter to Open/Pending/Approved (the data only contains open requests now). Tests: title resolution end-to-end (movie tmdbId → title), caching across polls, filter param used. 404/404 backend + 184/184 frontend + build green. --- .../clients/.pi-map.index.md | 2 +- .../clients/.pi-map.md | 8 +- .../clients/jellyseerr.py | 100 +++++++++++------- .../widgets/jellyseerr_stats.py | 6 +- backend/tests/test_jellyseerr_stats.py | 66 +++++++----- frontend/src/components/.pi-map.md | 4 +- .../src/components/JellyseerRequestsTable.tsx | 41 +++++-- 7 files changed, 145 insertions(+), 82 deletions(-) diff --git a/backend/src/media_library_viewer_api/clients/.pi-map.index.md b/backend/src/media_library_viewer_api/clients/.pi-map.index.md index 6ce8674..3f3db6a 100644 --- a/backend/src/media_library_viewer_api/clients/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/clients/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/clients ## role -Provides API and system client wrappers for external services (Authentik, Jellyfin, Jellyseerr, qBittorrent, SSH/local) used by the media library viewer backend. +Collection of external service API clients and protocol wrappers that standardize communication with media servers, identity providers, torrent clients, and remote/local filesystems. ## parent index: backend/src/media_library_viewer_api/.pi-map.index.md map: backend/src/media_library_viewer_api/.pi-map.md diff --git a/backend/src/media_library_viewer_api/clients/.pi-map.md b/backend/src/media_library_viewer_api/clients/.pi-map.md index 9b242b1..e3227fc 100644 --- a/backend/src/media_library_viewer_api/clients/.pi-map.md +++ b/backend/src/media_library_viewer_api/clients/.pi-map.md @@ -4,20 +4,20 @@ dir: backend/src/media_library_viewer_api/clients index: backend/src/media_library_viewer_api/clients/.pi-map.index.md ## role -Provides API and system client wrappers for external services (Authentik, Jellyfin, Jellyseerr, qBittorrent, SSH/local) used by the media library viewer backend. +Collection of external service API clients and protocol wrappers that standardize communication with media servers, identity providers, torrent clients, and remote/local filesystems. ## files - __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh - authentik.py | API client wrapper for Authentik directory service providing paginated user browsing and search via REST API. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout - http_timeout.py | Provides a helper function to build decoupled (connect, read) timeout tuples for the `requests` library, allowing different timeout budgets for connection and read phases. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float - jellyfin.py | Wraps the Jellyfin/Emby HTTP API to provide methods for fetching users, libraries, media items, playback sessions, and image URLs as plain Python dictionaries. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:resolve_user_id(self, identifier: str | None) → str, call:self.users, call:any, call:str, call:u.get, call:next, call:logger.info, call:logger.warning, raise:RuntimeError, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout -- jellyseerr.py | HTTP API client wrapper for Jellyseerr that fetches users, request counts, and media request metadata to enrich Jellyfin user data. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info, method:request_count(self) → dict[str, int], call:self.get, call:isinstance, call:int, call:payload.get, call:logger.info, method:recent_requests(self, take) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:mapped.append, call:media.get, call:_label, call:(media or {}).get, method:requests(self, max_count) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:results.append, call:_label, call:(media or {}).get, call:len, call:logger.info, func:_label(value: Any, table: dict[int, str]) → str, call:table.get, call:int, call:str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout +- jellyseerr.py | HTTP API client for Jellyseerr that fetches and enriches Jellyfin user and request metadata. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:_resolve_title(self, media_type: Any, tmdb_id: Any) → str, call:str, call:self.get, call:data.get, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info, method:request_count(self) → dict[str, int], call:self.get, call:isinstance, call:int, call:payload.get, call:logger.info, method:recent_requests(self, take) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:media.get, call:self._resolve_title, call:mapped.append, call:_label, call:(media or {}).get, method:open_requests(self, max_per_filter) → list[dict[str, Any]], call:self.get, call:isinstance, call:payload.get, call:r.get, call:media.get, call:self._resolve_title, call:results.append, call:_label, call:(media or {}).get, call:len, call:results.sort, call:logger.info, func:_label(value: Any, table: dict[int, str]) → str, call:table.get, call:int, call:str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout - local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses - qbittorrent.py | Minimal read-only qBittorrent Web API client that authenticates via username/password and fetches/merges incremental sync/maindata snapshots with caching, locking, and exponential backoff. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:threading.Lock, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:time.time, call:self._snapshot.get, call:self._copy_snapshot, call:self._fetch_maindata_incremental, call:self._apply_update, call:min, call:logger.warning, raise:RuntimeError, method:_fetch_maindata_incremental(self) → dict[str, Any], call:self._get, method:_apply_update(self, update: dict[str, Any]) → None, call:bool, call:update.get, call:snap.clear, call:dict, call:list, call:isinstance, call:snap["server_state"].update, call:changed.items, call:snap["torrents"].pop, call:snap["categories"].update, call:snap["categories"].pop, method:_copy_snapshot(self) → dict[str, Any], call:dict, call:snap.get, call:list | dep: logging, threading, time, typing, requests, media_library_viewer_api.clients.http_timeout - ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts ## arch -Adapter pattern with consistent dictionary-based return types across heterogeneous clients (REST APIs, SSH, local shell), each encapsulating authentication, pagination, and service-specific protocol details. +Adapter/wrapper pattern around `requests` HTTP and SSH/paramiko protocols, with each client encapsulating authentication, data fetching, and response normalization into plain Python dictionaries. ## tags -call:logger.info, error, call:self.get, call:self., call:logger.debug, call:logger.warning, call:isinstance, client +call:logger.info, call:self.get, call:self., error, call:logger.debug, call:logger.warning, call:isinstance, client ## symbols - AuthentikClient - JellyfinClient diff --git a/backend/src/media_library_viewer_api/clients/jellyseerr.py b/backend/src/media_library_viewer_api/clients/jellyseerr.py index 8692ba3..123f13e 100644 --- a/backend/src/media_library_viewer_api/clients/jellyseerr.py +++ b/backend/src/media_library_viewer_api/clients/jellyseerr.py @@ -56,6 +56,7 @@ class JellyseerrClient: "Accept": "application/json", } ) + self._title_cache: dict[tuple[str, str], str] = {} def get(self, path: str, **params: Any) -> Any: """GET a Jellyseerr endpoint and include useful response text on errors.""" @@ -84,6 +85,26 @@ class JellyseerrClient: path = f"/{path}" return f"{self.base_url}{path}" + def _resolve_title(self, media_type: Any, tmdb_id: Any) -> str: + """Resolve a media title via /movie/{tmdbId} or /tv/{tmdbId}, cached. + + Jellyseerr's /request list doesn't include titles; they live on the + Movie/Series records. Cached per (type, tmdbId) so repeated polls reuse. + """ + if not tmdb_id: + return "" + key = (str(media_type or ""), str(tmdb_id)) + if key in self._title_cache: + return self._title_cache[key] + try: + is_tv = str(media_type) in ("2", "tv") + data = self.get(f"/{'tv' if is_tv else 'movie'}/{tmdb_id}") + title = str(data.get("name" if is_tv else "title") or "") + except Exception: + title = "" + self._title_cache[key] = title + return title + def jellyfin_users(self) -> list[dict[str, Any]]: """Return Jellyfin-linked users known to Jellyseerr. @@ -175,7 +196,7 @@ class JellyseerrClient: return counts def recent_requests(self, take: int = 20) -> list[dict[str, Any]]: - """Return the most recently modified requests, lightly mapped.""" + """Return the most recently modified requests with resolved titles.""" take = max(1, min(int(take), 100)) payload = self.get("/request", sort="modified", skip=0, take=take) if not isinstance(payload, dict): @@ -185,11 +206,15 @@ class JellyseerrClient: mapped: list[dict[str, Any]] = [] for r in items: media = r.get("media") or {} + tmdb_id = media.get("tmdbId") + name = r.get("title") or media.get("title") or media.get("name") or "" + if not name and tmdb_id: + name = self._resolve_title(r.get("type"), tmdb_id) mapped.append( { "id": r.get("id"), - "type": r.get("type"), - "name": r.get("title") or media.get("title") or media.get("name") or "—", + "type": _label(r.get("type"), _REQUEST_TYPE), + "name": name or "—", "status": _label(r.get("status"), _REQUEST_STATUS), "media_status": _label((media or {}).get("status"), _MEDIA_STATUS), "created_at": r.get("createdAt"), @@ -197,40 +222,43 @@ class JellyseerrClient: ) return mapped - def requests(self, max_count: int = 500) -> list[dict[str, Any]]: - """Return requests (paginated), mapped for the requests table. + def open_requests(self, max_per_filter: int = 100) -> list[dict[str, Any]]: + """Return open (pending + approved) requests with resolved titles. - Fetches up to ``max_count`` requests (no status filter, so the table - can filter/sort client-side). The table defaults to showing "open" - (pending/approved/processing) sorted by date added (newest first). + Fetches pending and approved requests via Jellyseerr's filter param + (not all 800+ historical requests), then resolves titles from + /movie/{tmdbId} or /tv/{tmdbId}. Titles are cached on the client so + subsequent polls are instant. """ - max_count = max(1, min(int(max_count), 1000)) results: list[dict[str, Any]] = [] - take = 100 - skip = 0 - while skip < max_count: - payload = self.get("/request", sort="added", skip=skip, take=take) - if not isinstance(payload, dict): - break - page = payload.get("results") or [] - items = [r for r in page if isinstance(r, dict)] if isinstance(page, list) else [] - for r in items: - media = r.get("media") or {} - results.append( - { - "id": r.get("id"), - "type": _label(r.get("type"), _REQUEST_TYPE), - "name": r.get("title") or (media or {}).get("title") or (media or {}).get("name") or "—", - "status": _label(r.get("status"), _REQUEST_STATUS), - "media_status": _label((media or {}).get("status"), _MEDIA_STATUS), - "created_at": r.get("createdAt"), - } - ) - if len(items) < take: - break - skip += len(items) - if len(results) >= max_count: - results = results[:max_count] - break - logger.info("Jellyseerr returned %s requests", len(results)) + take = 50 + for filter_val in ("pending", "approved"): + skip = 0 + while skip < max_per_filter: + payload = self.get("/request", filter=filter_val, sort="added", skip=skip, take=take) + if not isinstance(payload, dict): + break + page = payload.get("results") or [] + items = [r for r in page if isinstance(r, dict)] if isinstance(page, list) else [] + for r in items: + media = r.get("media") or {} + tmdb_id = media.get("tmdbId") + name = r.get("title") or media.get("title") or media.get("name") or "" + if not name and tmdb_id: + name = self._resolve_title(r.get("type"), tmdb_id) + results.append( + { + "id": r.get("id"), + "type": _label(r.get("type"), _REQUEST_TYPE), + "name": name or "—", + "status": _label(r.get("status"), _REQUEST_STATUS), + "media_status": _label((media or {}).get("status"), _MEDIA_STATUS), + "created_at": r.get("createdAt"), + } + ) + if len(items) < take: + break + skip += len(items) + results.sort(key=lambda r: r.get("created_at") or 0, reverse=True) + logger.info("Jellyseerr returned %s open requests (with titles)", len(results)) return results diff --git a/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py b/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py index 8d25635..1a82496 100644 --- a/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py +++ b/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py @@ -107,10 +107,8 @@ def fetch_jellyseer_requests(service: Any) -> list[dict[str, Any]]: tab and widgets don't each open a new session. """ base_url = str(service.config.get("jellyseerr_url") or "") - api_key = str( - (service.secrets or {}).get("jellyseerr_api_key") or service.config.get("jellyseerr_api_key") or "" - ) + api_key = str((service.secrets or {}).get("jellyseerr_api_key") or service.config.get("jellyseerr_api_key") or "") if not base_url or not api_key: return [] client = _jellyseer_client((service.id, base_url, api_key)) - return client.requests(500) + return client.open_requests() diff --git a/backend/tests/test_jellyseerr_stats.py b/backend/tests/test_jellyseerr_stats.py index 2e3d449..5dc7e76 100644 --- a/backend/tests/test_jellyseerr_stats.py +++ b/backend/tests/test_jellyseerr_stats.py @@ -117,42 +117,56 @@ def test_stat_widget_unknown_stat_returns_error(): assert "error" in data -def test_jellyseer_client_requests_maps_fields(): - """requests() paginates /request and maps type/status/media_status enums.""" +def test_jellyseer_client_open_requests_resolves_titles(): + """open_requests() fetches pending+approved via filter and resolves titles.""" from media_library_viewer_api.clients.jellyseerr import JellyseerrClient c = JellyseerrClient("https://js.example.com", "key") c.session = MagicMock() - resp = MagicMock() - resp.raise_for_status.return_value = None - resp.status_code = 200 - resp.text = "" - resp.json.return_value = { - "results": [ - { - "id": 7, - "type": 1, - "title": "Inception", - "status": 1, - "media": {"status": 5}, - "createdAt": 1_700_000_000, - } - ] - } - c.session.get.return_value = resp - out = c.requests(500) + def mock_get(url, **kw): + resp = MagicMock() + resp.raise_for_status.return_value = None + resp.status_code = 200 + resp.text = "" + params = kw.get("params", {}) + if "/movie/" in url or "/tv/" in url: + resp.json.return_value = {"title": "Inception"} # title resolution + elif params.get("filter") == "pending": + resp.json.return_value = { + "results": [ + { + "id": 7, + "type": 1, + "status": 1, + "media": {"tmdbId": 123, "status": 5}, + "createdAt": 1_700_000_000, + } + ] + } + else: + resp.json.return_value = {"results": []} + return resp + + c.session.get.side_effect = mock_get + + out = c.open_requests() assert len(out) == 1 r = out[0] assert r["id"] == 7 - assert r["type"] == "movie" # 1 -> movie - assert r["name"] == "Inception" - assert r["status"] == "pending" # 1 -> pending - assert r["media_status"] == "available" # 5 -> available + assert r["type"] == "movie" + assert r["name"] == "Inception" # resolved via /movie/123 + assert r["status"] == "pending" + assert r["media_status"] == "available" assert r["created_at"] == 1_700_000_000 - # Single short page -> no second fetch. - assert c.session.get.call_count == 1 + + # Title is cached: a second call doesn't re-fetch /movie/123. + movie_calls_before = sum(1 for call in c.session.get.call_args_list if "/movie/" in call.args[0]) + assert movie_calls_before == 1 + c.open_requests() # second poll + movie_calls_after = sum(1 for call in c.session.get.call_args_list if "/movie/" in call.args[0]) + assert movie_calls_after == 1 # cached, no new /movie call def test_fetch_jellyseer_requests_not_configured_returns_empty(): diff --git a/frontend/src/components/.pi-map.md b/frontend/src/components/.pi-map.md index 59f04dc..49fa7eb 100644 --- a/frontend/src/components/.pi-map.md +++ b/frontend/src/components/.pi-map.md @@ -13,7 +13,7 @@ Shared UI component library providing reusable React components for tables, card - ConfirmDialog.tsx | Reusable confirmation dialog component that wraps shadcn/ui Dialog primitives with standardized cancel/confirm footer behavior. | exp: func:ConfirmDialog({ open, title, message, confirmLabel = "Delete", onCancel, onConfirm, busy, }: { open: boolean; title: string; message: string; confirmLabel?: string; onCancel: () => void; onConfirm: () => void; busy?: boolean; }), call:onCancel | dep: @/components/ui/dialog, ./DialogFooter - DialogFooter.tsx | Renders a dialog footer component with cancel, optional secondary action, and confirm buttons, mapping legacy MUI color/variant props to shadcn Button variants. | exp: func:DialogFooter({ onCancel, cancelLabel = "Cancel", onConfirm, confirmLabel, confirmBusyLabel, confirmDisabled, confirmColor = "primary", confirmVariant = "contained", confirmStartIcon, secondaryAction, }: DialogFooterProps), call:resolveConfirmVariant | dep: react, @/components/ui/button - HoverEditButton.tsx | Renders a hover-reveal edit button for desktop and always-visible edit button for mobile, preserving legacy CSS class hooks. | exp: func:HoverEditButton({ onClick, label = "Edit", mobile = "always", }: HoverEditButtonProps), call:e.stopPropagation, call:onClick | dep: lucide-react, @/components/ui/button -- JellyseerRequestsTable.tsx | Renders a sortable, filterable, and paginated table of Jellyseerr media requests using TanStack Table with client-side search and status filtering. | exp: func:JellyseerRequestsTable({ serviceId }: { serviceId: string }), call:useJellyseerRequests, call:useState, call:useMemo, call:search.trim().toLowerCase, call:requests.filter, call:OPEN_STATUSES.has, call:String(r.name ?? "").toLowerCase().includes, call:useReactTable, call:getCoreRowModel, call:getSortedRowModel, call:getPaginationRowModel, call:setSearch, call:setStatusFilter, call:table.getHeaderGroups().map, call:hg.headers.map, call:header.column.getToggleSortingHandler, call:flexRender, call:header.getContext, call:header.column.getIsSorted, call:table.getRowModel().rows.map, call:row.getVisibleCells().map, call:cell.getContext, call:table.getState, call:table.getPageCount | dep: react, @tanstack/react-table, lucide-react, @/components/ui/alert, @/components/ui/badge, @/components/ui/input, @/components/ui/select, @/components/ui/skeleton, @/components/ui/table, @/components/ui/table-pagination, ../hooks/useJellyseer, ../api/jellyseerr +- JellyseerRequestsTable.tsx | Displays a sortable, filterable, and paginated table of Jellyseerr media requests fetched via a custom hook. | exp: func:JellyseerRequestsTable({ serviceId }: { serviceId: string }), call:useJellyseerRequests, call:useState, call:useMemo, call:search.trim().toLowerCase, call:requests.filter, call:OPEN_STATUSES.has, call:String(r.name ?? "") .toLowerCase() .includes, call:useReactTable, call:getCoreRowModel, call:getSortedRowModel, call:getPaginationRowModel, call:setSearch, call:setStatusFilter, call:table.getHeaderGroups().map, call:hg.headers.map, call:header.column.getToggleSortingHandler, call:flexRender, call:header.getContext, call:header.column.getIsSorted, call:table.getRowModel().rows.map, call:row.getVisibleCells().map, call:cell.getContext, call:table.getState, call:table.getPageCount | dep: react, @tanstack/react-table, lucide-react, @/components/ui/alert, @/components/ui/badge, @/components/ui/input, @/components/ui/select, @/components/ui/skeleton, @/components/ui/table, @/components/ui/table-pagination, ../hooks/useJellyseer, ../api/jellyseerr, @/components/ui/* (alert, badge, input, select, skeleton, table, table-pagination) - LibraryOverview.tsx | Renders a two-column responsive grid displaying movie and TV library counts using shadcn/ui Card components | exp: func:LibraryOverview({ libraries }: Props), call:libraries.filter, call:movieLibs.map, call:lib.total.toLocaleString, call:lib.movies.toLocaleString, call:tvLibs.map, call:lib.series.toLocaleString | dep: @/components/ui/card, ../types - LineSeriesChart.tsx | Renders multiple time-series as a responsive line chart with automatic metric scaling and formatting. | exp: SeriesPoint, ChartSeries, func:LineSeriesChart({ series, height = 300, unit = "none", scale = "auto", }: LineSeriesChartProps), call:series.reduce, call:Math.abs, call:metricScaleInfo, call:formatScaled, call:mergeSeries, call:formatTime, call:Number, call:fmt, call:series.map | dep: recharts, ../lib/metricFormat, metricFormat - MetricCard.tsx | Renders a compact metric display card with label, value, and optional subtext using Tailwind CSS styling. | exp: func:MetricCard({ label, value, subtext }: Props) | dep: @/components/ui/card @@ -27,7 +27,7 @@ Shared UI component library providing reusable React components for tables, card - WidgetConfigDialog.tsx | This file provides a React dialog component for creating, editing, reordering, and deleting dashboard widgets, including managing references to existing widgets. | exp: func:WidgetConfigDialog({ open, onClose, serviceId, dashboardScope, editWidgetId, }: Props), call:useWidgetInstances, call:useMemo, call:useServiceInstances, call:useTasks, call:useSaveWidgetInstance, call:useDeleteWidgetInstance, call:useWidgetReferences, call:useCreateWidgetReference, call:useDeleteWidgetReference, call:useDetachWidgetReference, call:useUpdateWidgetReference, call:useState, call:Boolean, call:useEffect, call:instances.find, call:references.find, call:startEdit, call:setDraft, call:SERVICE_REGISTRY[ services.find((s) => s.id === serviceId)?.service_type ?? "" ]?.widgets.find, call:services.find, call:setDraftBaseline, call:onClose, call:saveWidget.mutateAsync, call:reset, call:updateRef.mutateAsync, call:deleteWidget.mutateAsync, call:[...instances].sort, call:references.map, call:[...owned, ...refs].sort, call:instances.map, call:existingSearch.toLowerCase().trim, call:allWidgets .filter((w) => !onDashboard.has(w.id)) .filter, call:onDashboard.has, call:w.title.toLowerCase().includes, call:w.widget_kind.toLowerCase().includes, call:createRef.mutateAsync, call:deleteRef.mutateAsync, call:detachRef.mutateAsync, call:SERVICE_REGISTRY[ services.find((s) => s.id === draft.serviceId)?.service_type ?? "" ]?.widgets.find, call:useIsMobile, call:String, call:Number, call:combinedWidgets.map, call:bindingLabel, call:moveInstance, call:toggleEnabled, call:handleDetach, call:handleRemoveReference, call:removeInstance, call:setShowExisting, call:setExistingSearch, call:availableWidgets.map, call:handleAddReference, call:Object.values(BUILTIN_WIDGETS).map, call:startAddBuiltIn, call:services .filter((s) => s.enabled) // When scoped to a service Overview, only show widgets for THAT // service instance's type (not all services' widgets). .filter((s) => !serviceId || s.id === serviceId) .flatMap, call:(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map, call:startAddService, call:handleClose, call:JSON.stringify | dep: react, @/components/ui/dialog, @/components/ui/button, @/components/ui/input, @/components/ui/textarea, @/components/ui/label, @/components/ui/switch, @/components/ui/select, @/components/ui/badge, @/components/ui/alert, lucide-react, ../hooks/useWidgets, ../hooks/useServices, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../types, ../integrations/registry, @/components/ui/* - WidgetInstance.tsx | Renders a widget instance card that dynamically resolves and displays a widget component, with optional edit and copy actions. | exp: func:WidgetInstanceCard({ widget, onEdit, onCopy }: Props), call:useServiceInstances, call:resolveWidget, call:onCopy, call:onEdit | dep: @/components/ui/alert, @/components/ui/button, lucide-react, ../hooks/useServices, ../integrations/registry, ../types, ./SectionCard ## arch -Presentational React components built on shadcn/ui primitives with responsive design patterns, TanStack Table for data tables, and legacy API compatibility layers for MUI migration. +Presentational React functional components built on shadcn/ui primitives with Tailwind CSS, employing responsive design patterns (desktop table/mobile card switching), controlled component patterns, and legacy API compatibility layers for MUI-to-shadcn migration. ## tags call:use, components, ui, card, table, widget, backup, call:on ## symbols diff --git a/frontend/src/components/JellyseerRequestsTable.tsx b/frontend/src/components/JellyseerRequestsTable.tsx index a696467..75b0209 100644 --- a/frontend/src/components/JellyseerRequestsTable.tsx +++ b/frontend/src/components/JellyseerRequestsTable.tsx @@ -20,7 +20,13 @@ import { ArrowDown, ArrowUp, ChevronsUpDown, Search } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, @@ -35,14 +41,20 @@ import { useJellyseerRequests } from "../hooks/useJellyseer"; import type { JellyseerRequest } from "../api/jellyseerr"; const OPEN_STATUSES = new Set(["pending", "approved", "processing"]); -type StatusFilter = "open" | "all" | "pending" | "approved" | "declined"; +type StatusFilter = "open" | "pending" | "approved"; function formatDate(v?: number | string): string { if (!v) return "—"; const n = Number(v); const ms = n > 1e12 ? n : n * 1000; // seconds -> ms const d = new Date(ms); - return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); + return Number.isNaN(d.getTime()) + ? String(v) + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); } const columns: ColumnDef[] = [ @@ -86,7 +98,11 @@ const columns: ColumnDef[] = [ ]; export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) { - const { data: requests = [], isLoading, error } = useJellyseerRequests(serviceId); + const { + data: requests = [], + isLoading, + error, + } = useJellyseerRequests(serviceId); const [sorting, setSorting] = useState([ { id: "created_at", desc: true }, ]); @@ -99,10 +115,16 @@ export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) { const status = String(r.status ?? ""); if (statusFilter === "open") { if (!OPEN_STATUSES.has(status)) return false; - } else if (statusFilter !== "all" && status !== statusFilter) { + } else if (status !== statusFilter) { return false; } - if (q && !String(r.name ?? "").toLowerCase().includes(q)) return false; + if ( + q && + !String(r.name ?? "") + .toLowerCase() + .includes(q) + ) + return false; return true; }); }, [requests, statusFilter, search]); @@ -152,10 +174,8 @@ export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) { Open - All Pending Approved - Declined @@ -197,7 +217,10 @@ export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) { {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} ))}