feat(plex): expanded metadata filters, clickable info page, image disk cache

Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).

Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
This commit is contained in:
npeter83 2026-07-05 23:45:55 +02:00
parent 690e17611c
commit eefd7e3abd
15 changed files with 842 additions and 122 deletions

View file

@ -720,6 +720,43 @@ export interface PlexCastMember {
role?: string | null;
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
}
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
export interface PlexFilters {
genres: string[];
contentRatings: string[];
yearMin?: number | null;
yearMax?: number | null;
ratingMin?: number | null;
durationMin?: number | null; // seconds
durationMax?: number | null;
addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
director?: string | null;
actor?: string | null;
studio?: string | null;
}
export const EMPTY_PLEX_FILTERS: PlexFilters = { genres: [], contentRatings: [] };
export function plexFilterCount(f: PlexFilters): number {
return (
f.genres.length +
f.contentRatings.length +
(f.yearMin != null || f.yearMax != null ? 1 : 0) +
(f.ratingMin != null ? 1 : 0) +
(f.durationMin != null || f.durationMax != null ? 1 : 0) +
(f.addedWithin ? 1 : 0) +
(f.director ? 1 : 0) +
(f.actor ? 1 : 0) +
(f.studio ? 1 : 0)
);
}
export interface PlexFacets {
genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[];
year_min: number | null;
year_max: number | null;
rating_max: number | null;
duration_min: number | null;
duration_max: number | null;
}
export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;
@ -1049,6 +1086,7 @@ export const api = {
show?: string;
offset?: number;
limit?: number;
filters?: PlexFilters;
}): Promise<PlexBrowseResult> => {
const u = new URLSearchParams({ library: p.library });
if (p.q) u.set("q", p.q);
@ -1056,8 +1094,24 @@ export const api = {
if (p.show && p.show !== "all") u.set("show", p.show);
if (p.offset) u.set("offset", String(p.offset));
if (p.limit) u.set("limit", String(p.limit));
const f = p.filters;
if (f) {
if (f.genres?.length) u.set("genres", f.genres.join(","));
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
if (f.addedWithin) u.set("added_within", f.addedWithin);
if (f.director) u.set("director", f.director);
if (f.actor) u.set("actor", f.actor);
if (f.studio) u.set("studio", f.studio);
}
return req(`/api/plex/browse?${u.toString()}`);
},
plexFacets: (library: string): Promise<PlexFacets> =>
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
plexShow: (id: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
plexItem: (id: string): Promise<PlexItemDetail> =>