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

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