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
@@ -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