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:
parent
0385b13a28
commit
418a874851
14 changed files with 386 additions and 15 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue