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

@ -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>
</>