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