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:
Developer
2026-07-13 09:58:25 +00:00
parent 7665ef4d10
commit 45c295457a
7 changed files with 145 additions and 82 deletions
@@ -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()