feat(plex): unify movies + shows into one cross-library browser
Collapse the separate Movie/Show library sections into ONE unified library with a shared search + shared filter sidebar and a Movies/Shows/Both scope selector. Backend: new GET /api/plex/library — a cross-library UNION of movies (plex_items) and shows (plex_shows) as one mixed, paginated, sorted feed, scoped movie|show|both, with the shared filters (extracted into _apply_meta_filters, DRY), FTS search, and per-user watch-state (a show's state = the aggregate of its episodes). On a search that also matches episodes, matching episodes come back in a separate 'episodes' list (the grouped 'Episodes' section — Proposal 3). /facets is now scope-aware (merged across the scope's libraries). /item and /show now return their library section key (for the admin collection editor, since there's no single library prop in the unified view). Frontend: PlexSidebar's library picker -> a scope selector (Both/Movies/Shows); facets + browse follow the scope (App's plexLib repurposed to a validated scope, default both). PlexBrowse uses the unified endpoint, renders a mixed Titles grid + an Episodes section on search. Poster cards are generalized: a hover Play/Resume overlay on every card, a clickable title (not just the poster), and a movie/show type tag. The quick watched toggle is now optimistic so it reliably flips BOTH ways (fixes the movie card that could mark but not un-mark). Cast/crew members and the show hero's meta (year/rating/genre/ content-rating) are clickable filters; clicking a person widens the scope to Both so the result is a mixed movie+show feed. i18n plex.filter.scope*/plex.unified.* (en/hu/de). Still pending from the polish list (next pass): season-card quick toggles (watched + add-to-playlist), per-episode watched toggle, and the full glassy art-bg + hide-cast customize menu on the series pages.
This commit is contained in:
parent
11b7558c6c
commit
736db017e4
9 changed files with 579 additions and 269 deletions
|
|
@ -663,6 +663,16 @@ export interface PlexBrowseResult {
|
|||
limit: number;
|
||||
items: PlexCard[];
|
||||
}
|
||||
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
|
||||
// also matches episodes (the grouped "Episodes" section).
|
||||
export interface PlexUnifiedResult {
|
||||
scope: string; // movie | show | both
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: PlexCard[];
|
||||
episodes: PlexCard[];
|
||||
}
|
||||
export interface PlexSeasonDetail {
|
||||
id: string; // season rating_key
|
||||
season_number: number | null;
|
||||
|
|
@ -682,6 +692,7 @@ export interface PlexShowDetail {
|
|||
year?: number | null;
|
||||
thumb: string;
|
||||
art: string;
|
||||
library?: string | null; // Plex section key (for the admin collection editor)
|
||||
content_rating?: string | null;
|
||||
rating?: number | null;
|
||||
genres: string[];
|
||||
|
|
@ -716,6 +727,7 @@ export interface PlexItemDetail {
|
|||
playable: string; // direct | remux | transcode
|
||||
thumb: string;
|
||||
art: string;
|
||||
library?: string | null; // Plex section key (for the admin collection editor)
|
||||
cast: PlexCastMember[];
|
||||
imdb_rating?: number | null;
|
||||
imdb_id?: string | null;
|
||||
|
|
@ -1164,16 +1176,16 @@ export const api = {
|
|||
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
|
||||
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
|
||||
req("/api/plex/libraries"),
|
||||
plexBrowse: (p: {
|
||||
library: string;
|
||||
plexLibrary: (p: {
|
||||
scope: string; // movie | show | both
|
||||
q?: string;
|
||||
sort?: string;
|
||||
show?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
filters?: PlexFilters;
|
||||
}): Promise<PlexBrowseResult> => {
|
||||
const u = new URLSearchParams({ library: p.library });
|
||||
}): Promise<PlexUnifiedResult> => {
|
||||
const u = new URLSearchParams({ scope: p.scope });
|
||||
if (p.q) u.set("q", p.q);
|
||||
if (p.sort) u.set("sort", p.sort);
|
||||
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||
|
|
@ -1196,10 +1208,10 @@ export const api = {
|
|||
if (f.collection) u.set("collection", f.collection);
|
||||
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
||||
}
|
||||
return req(`/api/plex/browse?${u.toString()}`);
|
||||
return req(`/api/plex/library?${u.toString()}`);
|
||||
},
|
||||
plexFacets: (library: string): Promise<PlexFacets> =>
|
||||
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
||||
plexFacets: (scope: string): Promise<PlexFacets> =>
|
||||
req(`/api/plex/facets?scope=${encodeURIComponent(scope)}`),
|
||||
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
|
||||
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
||||
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue