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

@ -142,6 +142,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
id={infoId}
onBack={sub.back}
onPlay={() => sub.open({ kind: "player", id: infoId })}
onPlayItem={(id) => sub.open({ kind: "player", id })}
onFilter={(patch) => {
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
@ -360,11 +361,13 @@ function PlexInfoView({
id,
onBack,
onPlay,
onPlayItem,
onFilter,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
@ -384,7 +387,13 @@ function PlexInfoView({
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
) : (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexInfo detail={q.data} variant="page" onPlay={onPlay} onFilter={onFilter} />
<PlexInfo
detail={q.data}
variant="page"
onPlay={onPlay}
onPlayItem={onPlayItem}
onFilter={onFilter}
/>
</Suspense>
)}
</div>

View file

@ -27,6 +27,8 @@ type Props = {
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
// the corresponding Plex filter (then the opener navigates to the filtered grid).
onFilter?: (patch: Partial<PlexFilters>) => void;
// Play a sibling title from a collection strip (opens its player).
onPlayItem?: (id: string) => void;
};
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
@ -56,7 +58,15 @@ function fmtDur(s?: number | null): string {
return h ? `${h}h ${m}m` : `${m}m`;
}
export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange, onFilter }: Props) {
export default function PlexInfo({
detail,
variant,
onPlay,
onClose,
onStateChange,
onFilter,
onPlayItem,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
@ -346,6 +356,61 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
</div>
</div>
)}
{/* Collection strips — the other titles in this movie's collection(s), directly playable. */}
{detail.collections?.map((col) => (
<div key={col.id} className="mt-6">
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>
{onFilter && (
<button
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
</button>
)}
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{col.items.map((m) => (
<button
key={m.id}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left"
>
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-surface">
<img
src={m.thumb}
alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105"
/>
{m.status === "watched" && (
<span className="absolute right-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white"></span>
)}
{onPlayItem && (
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
<Play className="h-8 w-8 text-white" fill="currentColor" />
</div>
)}
{(m.position_seconds ?? 0) > 0 && m.status !== "watched" && m.duration_seconds ? (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
<div
className="h-full bg-accent"
style={{ width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%` }}
/>
</div>
) : null}
</div>
<div className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}>
{m.title}
</div>
{m.year ? <div className="text-[11px] text-muted">{m.year}</div> : null}
</button>
))}
</div>
</div>
))}
</div>
</div>
);

View file

@ -1,8 +1,9 @@
import { useEffect, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Film, SlidersHorizontal, Tv2, X } from "lucide-react";
import { ChevronRight, Film, Layers, SlidersHorizontal, Tv2, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
// full metadata filter set (genre / rating / year / duration / added / content rating + the
@ -60,6 +61,16 @@ export default function PlexSidebar({
});
const facets = facetsQ.data;
// Collections picker (searchable; the list is only fetched when none is active).
const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({
queryKey: ["plex-collections", library, collSearchDeb],
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
enabled: !!library && isMovieLib && !filters.collection,
});
const collections = collectionsQ.data?.collections ?? [];
// Default to the first library once loaded (or if the stored one vanished).
useEffect(() => {
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
@ -205,6 +216,43 @@ export default function PlexSidebar({
</Section>
)}
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
<Section label={t("plex.filter.collection")}>
{filters.collection ? (
<div className="flex flex-wrap gap-1.5">
<RemovableChip
label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })}
/>
</div>
) : (
<div>
<input
value={collSearch}
onChange={(e) => setCollSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
/>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{collections.map((c) => (
<button
key={c.id}
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
>
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{c.title}</span>
<span className="text-xs text-muted">{c.child_count}</span>
</button>
))}
{collections.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
)}
</div>
</div>
)}
</Section>
{/* IMDb rating min */}
<Section label={t("plex.filter.rating")}>
<ChipRow>

View file

@ -33,7 +33,10 @@
"addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" },
"contentRating": "Altersfreigabe",
"match": { "any": "Beliebig", "all": "Alle" },
"dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" }
"dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" },
"collection": "Sammlung",
"collectionSearch": "Sammlungen suchen…",
"collectionEmpty": "Keine Sammlungen"
},
"count": "{{count}} Titel",
"searchCount": "{{count}} Treffer",
@ -103,7 +106,8 @@
"clearResume": "Fortsetzungsposition löschen",
"filterYear": "Titel aus {{year}}",
"filterRating": "Titel mit {{n}}+",
"filterActor": "Titel mit {{name}}"
"filterActor": "Titel mit {{name}}",
"browseCollection": "Sammlung durchsuchen →"
},
"playable": {
"direct": "Spielt direkt im Browser",

View file

@ -33,7 +33,10 @@
"addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" },
"contentRating": "Age rating",
"match": { "any": "Any", "all": "All" },
"dir": { "desc": "↓ Descending", "asc": "↑ Ascending" }
"dir": { "desc": "↓ Descending", "asc": "↑ Ascending" },
"collection": "Collection",
"collectionSearch": "Search collections…",
"collectionEmpty": "No collections"
},
"count": "{{count}} titles",
"searchCount": "{{count}} matches",
@ -103,7 +106,8 @@
"clearResume": "Clear resume position",
"filterYear": "Show titles from {{year}}",
"filterRating": "Show titles rated {{n}}+",
"filterActor": "Show titles with {{name}}"
"filterActor": "Show titles with {{name}}",
"browseCollection": "Browse collection →"
},
"playable": {
"direct": "Plays directly in the browser",

View file

@ -33,7 +33,10 @@
"addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" },
"contentRating": "Korhatár",
"match": { "any": "Bármelyik", "all": "Mind" },
"dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" }
"dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" },
"collection": "Kollekció",
"collectionSearch": "Kollekciók keresése…",
"collectionEmpty": "Nincs kollekció"
},
"count": "{{count}} cím",
"searchCount": "{{count}} találat",
@ -103,7 +106,8 @@
"clearResume": "Folytatási pozíció törlése",
"filterYear": "{{year}} évi címek",
"filterRating": "{{n}}+ pontos címek",
"filterActor": "Címek {{name}} szereplésével"
"filterActor": "Címek {{name}} szereplésével",
"browseCollection": "Kollekció böngészése →"
},
"playable": {
"direct": "Közvetlenül játszható a böngészőben",

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",