feat(plex): collections — sync, browse, filter, and info-page strips (Phase 1, read)

Backend (migration 0047_plex_collections): a plex_collections table mirrors every Plex
collection (card metadata + smart flag + an editable flag reserved for Phase 2); membership
is stored as GIN-indexed collection_keys on member movies (plex_items) and shows (plex_shows).
The background sync (plex_sync) now fetches each library's collections + their children and
rebuilds membership — so ALL reads are local (Plex's dual-language collection queries are slow;
this trades a ~4-min background sync for instant reads). New /api/plex/collections endpoint;
/browse gains a combinable  filter; item_detail returns the movie's collection
'strips' (sibling titles as playable cards, smallest/most-specific collection first) — all pure
local lookups.

Frontend: PlexSidebar gains a searchable Collection picker + an active-collection chip;
PlexInfo renders the collection strips (playable posters + 'Browse collection' → sets the
filter); the collection is part of PlexFilters (persisted). i18n en/hu/de.

Phase 2 (create/edit collections with write-back to Plex) is separate.
This commit is contained in:
npeter83 2026-07-06 02:25:48 +02:00
parent 0385b13a28
commit 418a874851
14 changed files with 386 additions and 15 deletions

View file

@ -714,6 +714,16 @@ export interface PlexItemDetail {
episode_number?: number | null;
prev_id?: string | null;
next_id?: string | null;
collections?: { id: string; title: string; items: PlexCard[] }[];
}
export interface PlexCollection {
id: string;
title: string;
summary?: string | null;
thumb?: string | null;
child_count?: number | null;
smart: boolean;
editable: boolean;
}
export interface PlexCastMember {
name: string;
@ -734,6 +744,8 @@ export interface PlexFilters {
directors: string[]; // AND — titles featuring ALL selected
actors: string[]; // AND — titles featuring ALL selected
studios: string[]; // OR — from any selected studio
collection?: string | null; // a single Plex collection (rating_key)
collectionTitle?: string | null; // its title, for the active-filter chip
sortDir?: "asc" | "desc"; // sort direction (default desc)
}
export const EMPTY_PLEX_FILTERS: PlexFilters = {
@ -753,7 +765,8 @@ export function plexFilterCount(f: PlexFilters): number {
(f.addedWithin ? 1 : 0) +
f.directors.length +
f.actors.length +
f.studios.length
f.studios.length +
(f.collection ? 1 : 0)
);
}
export interface PlexPerson {
@ -1122,6 +1135,7 @@ export const api = {
if (f.directors?.length) u.set("directors", f.directors.join(","));
if (f.actors?.length) u.set("actors", f.actors.join(","));
if (f.studios?.length) u.set("studios", f.studios.join(","));
if (f.collection) u.set("collection", f.collection);
if (f.sortDir === "asc") u.set("sort_dir", "asc");
}
return req(`/api/plex/browse?${u.toString()}`);
@ -1130,6 +1144,8 @@ export const api = {
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
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[] }> =>
req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`),
plexShow: (id: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
plexItem: (id: string): Promise<PlexItemDetail> =>

View file

@ -14,6 +14,16 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.28.0",
date: "2026-07-06",
summary: "Plex collections — browse them, filter by them, and see a title's collection right on its info page.",
features: [
"Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries.",
"Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter.",
"Plex info page: a title's collection(s) now appear as strips of the sibling films, playable straight from there (most-specific collection first).",
],
},
{
version: "0.27.0",
date: "2026-07-06",