feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)

Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users
without a Plex account, like watch-state) — the "your own lists" counterpart to
shared collections. Plex-direction sync is a later phase (plex_rating_key
reserved).

Backend: migration 0048 (plex_playlists + plex_playlist_items with position),
PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under
/api/plex/playlists (list [+?contains for the add dialog], create [seeded],
detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card
handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie
info page (all users), a Playlists section in PlexSidebar (list + create),
PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and
PlexPlayer gained an optional `queue` so play-through follows the list order
(prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev
(backend CRUD + the create→add→view→play-through UI flow).
This commit is contained in:
npeter83 2026-07-06 19:05:12 +02:00
parent 1fd3003038
commit 25197ed817
16 changed files with 796 additions and 18 deletions

View file

@ -9,12 +9,14 @@ import { useHistorySubview } from "../lib/history";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
const PlexInfo = lazy(() => import("./PlexInfo"));
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
type Sub =
| { kind: "grid" }
| { kind: "show"; id: string }
| { kind: "player"; id: string }
| { kind: "info"; id: string };
| { kind: "player"; id: string; queue?: string[] }
| { kind: "info"; id: string }
| { kind: "playlist"; id: number };
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
@ -30,6 +32,9 @@ type Props = {
sort: string;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
// The sidebar opens a playlist by setting this; PlexBrowse turns it into a history subview + clears it.
openPlaylist?: number | null;
onPlaylistOpened?: () => void;
};
const PAGE = 40;
@ -41,12 +46,32 @@ function dur(n?: number | null): string {
return h ? `${h}h ${m}m` : `${m}m`;
}
export default function PlexBrowse({ q, onClearSearch, library, show, sort, filters, setFilters }: Props) {
export default function PlexBrowse({
q,
onClearSearch,
library,
show,
sort,
filters,
setFilters,
openPlaylist,
onPlaylistOpened,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const dq = useDebounced(q.trim(), 350);
const sub = useHistorySubview<Sub>({ kind: "grid" });
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
// clear the signal so it doesn't re-open.
useEffect(() => {
if (openPlaylist != null) {
sub.open({ kind: "playlist", id: openPlaylist });
onPlaylistOpened?.();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openPlaylist]);
// Opening a show/player replaces the grid entirely (the component returns early below), so the
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
@ -137,7 +162,19 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
if (sub.view.kind === "player") {
return (
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
<PlexPlayer itemId={sub.view.id} onClose={sub.back} />
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
</Suspense>
);
}
if (sub.view.kind === "playlist") {
const pid = sub.view.id;
return (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexPlaylistView
playlistId={pid}
onBack={sub.back}
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
/>
</Suspense>
);
}

View file

@ -6,6 +6,7 @@ import {
Check,
ExternalLink,
FolderPlus,
ListPlus,
Play,
RotateCcw,
SlidersHorizontal,
@ -14,6 +15,7 @@ import {
} from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@ -95,6 +97,7 @@ export default function PlexInfo({
};
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false);
const [customizing, setCustomizing] = useState(false);
const custBtnRef = useRef<HTMLButtonElement>(null);
const custMenuRef = useRef<HTMLDivElement>(null);
@ -367,6 +370,15 @@ export default function PlexInfo({
{t("plex.info.clearResume")}
</button>
)}
{detail.kind === "movie" && (
<button
onClick={() => setPlaylistOpen(true)}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
>
<ListPlus className="w-4 h-4" />
{t("plex.playlistAdd.manage")}
</button>
)}
{isAdmin && detail.kind === "movie" && library && (
<button
onClick={() => setEditorOpen(true)}
@ -513,6 +525,9 @@ export default function PlexInfo({
onChanged={() => onStateChange?.()}
/>
)}
{playlistOpen && (
<PlexPlaylistAdd item={{ id: detail.id, title: detail.title }} onClose={() => setPlaylistOpen(false)} />
)}
</div>
);
}

View file

@ -32,7 +32,9 @@ const PlexInfo = lazy(() => import("./PlexInfo"));
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
// restart the session at the target. Watch state / resume persist to plex_states.
type Props = { itemId: string; onClose: () => void };
// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
// the queue instead of the item's own episode neighbours.
type Props = { itemId: string; onClose: () => void; queue?: string[] };
function fmt(t: number): string {
if (!isFinite(t) || t < 0) t = 0;
@ -43,7 +45,7 @@ function fmt(t: number): string {
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export default function PlexPlayer({ itemId, onClose }: Props) {
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const [id, setId] = useState(itemId);
@ -302,6 +304,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
[],
);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
const qIdx = queue ? queue.indexOf(id) : -1;
const prevId = queue ? (qIdx > 0 ? queue[qIdx - 1] : null) : detail?.prev_id;
const nextId = queue ? (qIdx >= 0 && qIdx < queue.length - 1 ? queue[qIdx + 1] : null) : detail?.next_id;
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
const download = useCallback(() => {
const a = document.createElement("a");
@ -385,11 +392,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
if (!video) return;
const onEnded = () => {
api.plexSetState(id, "watched").catch(() => {});
if (detail?.next_id) go(detail.next_id);
if (nextId) go(nextId);
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, detail, go]);
}, [id, nextId, go]);
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
const wake = useCallback(() => {
@ -418,12 +425,12 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
break;
case "ArrowLeft":
e.preventDefault();
if (e.shiftKey) go(detail?.prev_id);
if (e.shiftKey) go(prevId);
else seekTo(absRef.current - 10);
break;
case "ArrowRight":
e.preventDefault();
if (e.shiftKey) go(detail?.next_id);
if (e.shiftKey) go(nextId);
else seekTo(absRef.current + 10);
break;
case "ArrowUp":
@ -464,7 +471,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]);
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, prevId, nextId, onClose, wake]);
const activeMarker: PlexMarker | undefined = detail?.markers.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
@ -590,12 +597,12 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
<Square className="w-5 h-5" />
</Ctrl>
{detail?.kind === "episode" && (
{(detail?.kind === "episode" || queue) && (
<>
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
<Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
<SkipBack className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.next")} onClick={() => go(detail?.next_id)} disabled={!detail?.next_id}>
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
<SkipForward className="w-5 h-5" />
</Ctrl>
</>

View file

@ -0,0 +1,115 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react";
import { api, type PlexPlaylist } from "../lib/api";
import Modal from "./Modal";
// "Add this title to a playlist" dialog — per-user (every user has their own playlists), opened from
// the movie info page. Lists my playlists with an in/out toggle (seeded from the backend's has_item)
// and a field to create a new one seeded with this title. Playlists are personal (no admin gate).
export default function PlexPlaylistAdd({
item,
onClose,
}: {
item: { id: string; title: string };
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const [override, setOverride] = useState<Map<number, boolean>>(new Map()); // optimistic toggle state
const listQ = useQuery({
queryKey: ["plex-playlists", "contains", item.id],
queryFn: () => api.plexPlaylists(item.id),
});
const playlists = listQ.data?.playlists ?? [];
const isIn = (p: PlexPlaylist) => (override.has(p.id) ? override.get(p.id)! : !!p.has_item);
const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] });
async function toggle(p: PlexPlaylist) {
if (busy) return;
setBusy(true);
const cur = isIn(p);
try {
if (cur) await api.plexPlaylistRemoveItem(p.id, item.id);
else await api.plexPlaylistAddItem(p.id, item.id);
setOverride((m) => new Map(m).set(p.id, !cur));
refresh();
} finally {
setBusy(false);
}
}
async function createNew() {
const title = newName.trim();
if (!title || busy) return;
setBusy(true);
try {
const pl = await api.plexCreatePlaylist(title, item.id);
setNewName("");
setOverride((m) => new Map(m).set(pl.id, true));
refresh();
} finally {
setBusy(false);
}
}
return (
<Modal title={t("plex.playlistAdd.title", { title: item.title })} onClose={onClose}>
<div className="mb-3 flex gap-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createNew()}
placeholder={t("plex.playlistAdd.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
onClick={createNew}
disabled={!newName.trim() || busy}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
>
<Plus className="h-4 w-4" />
{t("plex.playlistAdd.create")}
</button>
</div>
<div className="max-h-80 space-y-1 overflow-y-auto">
{listQ.isLoading ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
) : playlists.length === 0 ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.playlistAdd.none")}</p>
) : (
playlists.map((p) => {
const inIt = isIn(p);
return (
<button
key={p.id}
onClick={() => toggle(p)}
disabled={busy}
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm disabled:opacity-50 ${
inIt ? "text-accent" : ""
}`}
>
<span
className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${
inIt ? "border-accent bg-accent/20" : "border-border"
}`}
>
{inIt && <Check className="h-3.5 w-3.5" />}
</span>
<span className="min-w-0 flex-1 truncate">{p.title}</span>
<span className="shrink-0 text-xs text-muted">
{t("plex.playlistAdd.count", { count: p.item_count })}
</span>
</button>
);
})
)}
</div>
</Modal>
);
}

View file

@ -0,0 +1,184 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, ChevronDown, ChevronUp, Pencil, Play, Trash2, X } from "lucide-react";
import { api, type PlexCard } from "../lib/api";
import { useConfirm } from "./ConfirmProvider";
// A playlist's ordered items — reorder (up/down), remove, rename, delete, and "Play all" (which opens
// the player with the whole ordered list as its queue). Rendered as a PlexBrowse subview.
export default function PlexPlaylistView({
playlistId,
onBack,
onPlay,
}: {
playlistId: number;
onBack: () => void;
onPlay: (itemRk: string, queue: string[]) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const q = useQuery({ queryKey: ["plex-playlist", playlistId], queryFn: () => api.plexPlaylist(playlistId) });
const items: PlexCard[] = q.data?.items ?? [];
const order = items.map((i) => i.id);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] });
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
};
async function move(idx: number, dir: -1 | 1) {
const to = idx + dir;
if (busy || to < 0 || to >= order.length) return;
setBusy(true);
const next = [...order];
[next[idx], next[to]] = [next[to], next[idx]];
try {
await api.plexReorderPlaylist(playlistId, next);
invalidate();
} finally {
setBusy(false);
}
}
async function remove(itemRk: string) {
if (busy) return;
setBusy(true);
try {
await api.plexPlaylistRemoveItem(playlistId, itemRk);
invalidate();
} finally {
setBusy(false);
}
}
async function rename() {
const title = name.trim();
if (!title) return;
await api.plexRenamePlaylist(playlistId, title);
setRenaming(false);
invalidate();
}
async function del() {
if (!(await confirm({ message: t("plex.playlist.deleteConfirm", { title: q.data?.title }), danger: true })))
return;
await api.plexDeletePlaylist(playlistId);
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
onBack();
}
return (
<div className="w-[90%] max-w-[1100px] mx-auto p-4">
<button
onClick={onBack}
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
>
<ArrowLeft className="h-4 w-4" />
{t("plex.backToLibrary")}
</button>
<div className="glass rounded-2xl p-4 sm:p-6">
{/* Header: title (rename) + Play all + delete */}
<div className="mb-4 flex flex-wrap items-center gap-3">
{renaming ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && rename()}
onBlur={rename}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold outline-none focus:border-accent"
/>
) : (
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)}
<button
onClick={() => {
setName(q.data?.title ?? "");
setRenaming(true);
}}
title={t("plex.playlist.rename")}
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-accent"
>
<Pencil className="h-4 w-4" />
</button>
{items.length > 0 && (
<button
onClick={() => onPlay(order[0], order)}
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
>
<Play className="h-5 w-5 fill-current" />
{t("plex.playlist.playAll")}
</button>
)}
<button
onClick={del}
title={t("plex.playlist.delete")}
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-red-400"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{q.isLoading ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.loading")}</p>
) : items.length === 0 ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
) : (
<ol className="space-y-1.5">
{items.map((it, idx) => (
<li key={it.id} className="glass-card flex items-center gap-3 rounded-lg p-2">
<span className="w-5 shrink-0 text-center text-xs text-muted">{idx + 1}</span>
<button
onClick={() => onPlay(it.id, order)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
>
<div className="relative h-14 w-10 shrink-0 overflow-hidden rounded border border-border bg-surface">
<img src={it.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover:opacity-100">
<Play className="h-5 w-5 text-white" fill="currentColor" />
</div>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{it.type === "episode" && it.show_title ? `${it.show_title}${it.title}` : it.title}
</div>
<div className="text-xs text-muted">{it.year}</div>
</div>
</button>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => move(idx, -1)}
disabled={busy || idx === 0}
className="rounded p-1 text-muted hover:text-fg disabled:opacity-30"
title={t("plex.playlist.up")}
>
<ChevronUp className="h-4 w-4" />
</button>
<button
onClick={() => move(idx, 1)}
disabled={busy || idx === items.length - 1}
className="rounded p-1 text-muted hover:text-fg disabled:opacity-30"
title={t("plex.playlist.down")}
>
<ChevronDown className="h-4 w-4" />
</button>
<button
onClick={() => remove(it.id)}
disabled={busy}
className="rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
title={t("plex.playlist.remove")}
>
<X className="h-4 w-4" />
</button>
</div>
</li>
))}
</ol>
)}
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Film, Layers, SlidersHorizontal, Tv2, X } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
@ -20,6 +20,7 @@ type Props = {
setSort: (v: string) => void;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
onOpenPlaylist: (id: number) => void;
collapsed: boolean;
onToggleCollapse: () => void;
};
@ -45,11 +46,23 @@ export default function PlexSidebar({
setSort,
filters,
setFilters,
onOpenPlaylist,
collapsed,
onToggleCollapse,
}: Props) {
const { t } = useTranslation();
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const qc = useQueryClient();
async function createPlaylist() {
const title = newPlaylist.trim();
if (!title) return;
const pl = await api.plexCreatePlaylist(title);
setNewPlaylist("");
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
onOpenPlaylist(pl.id);
}
const libs = libsQ.data?.libraries ?? [];
const activeLib = libs.find((l) => l.key === library);
const isMovieLib = activeLib?.kind === "movie";
@ -143,6 +156,45 @@ export default function PlexSidebar({
</div>
</Section>
{/* Playlists per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not
a filter. Available in any library. */}
<Section label={t("plex.playlist.section")}>
<div className="mb-1.5 flex gap-1.5">
<input
value={newPlaylist}
onChange={(e) => setNewPlaylist(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
placeholder={t("plex.playlist.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
/>
<button
onClick={createPlaylist}
disabled={!newPlaylist.trim()}
title={t("plex.playlist.create")}
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
) : (
<div className="space-y-0.5">
{playlistsQ.data!.playlists.map((p) => (
<button
key={p.id}
onClick={() => onOpenPlaylist(p.id)}
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
>
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate">{p.title}</span>
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
</button>
))}
</div>
)}
</Section>
{anyActive && (
<button
onClick={clearAll}