fix(jellyseer): resolve request titles via /movie|tv endpoints
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<JellyseerRequest>[] = [
|
||||
@@ -86,7 +98,11 @@ const columns: ColumnDef<JellyseerRequest>[] = [
|
||||
];
|
||||
|
||||
export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) {
|
||||
const { data: requests = [], isLoading, error } = useJellyseerRequests(serviceId);
|
||||
const {
|
||||
data: requests = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useJellyseerRequests(serviceId);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ 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 }) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="declined">Declined</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -197,7 +217,10 @@ export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) {
|
||||
<TableRow key={String(row.original.id ?? row.index)}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
Reference in New Issue
Block a user