Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-06 20:22:13 +02:00
commit 02235e5056
16 changed files with 796 additions and 18 deletions

View file

@ -1 +1 @@
0.29.0
0.30.0

View file

@ -0,0 +1,58 @@
"""Plex playlists (Siftlode-native, per-user, ordered)
Revision ID: 0048_plex_playlists
Revises: 0047_plex_collections
Create Date: 2026-07-06
Per-user ORDERED watch-lists of Plex items, kept in Siftlode's own DB so they work for users without a
Plex account (like plex_states) the "your own lists" counterpart to the shared, library-level
collections. `plex_playlists` is owned by a user; `plex_playlist_items` holds the ordered entries
(`position`). `plex_rating_key` is reserved for an optional later Plex-direction sync.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0048_plex_playlists"
down_revision: Union[str, None] = "0047_plex_collections"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"plex_playlists",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("title", sa.String(length=512), nullable=False),
sa.Column("plex_rating_key", sa.String(length=32), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_plex_playlists_user_id", "plex_playlists", ["user_id"])
op.create_table(
"plex_playlist_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"playlist_id", sa.Integer(),
sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"item_id", sa.Integer(),
sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),
)
op.create_index("ix_plex_playlist_items_playlist_id", "plex_playlist_items", ["playlist_id"])
op.create_index("ix_plex_playlist_items_item_id", "plex_playlist_items", ["item_id"])
def downgrade() -> None:
op.drop_index("ix_plex_playlist_items_item_id", table_name="plex_playlist_items")
op.drop_index("ix_plex_playlist_items_playlist_id", table_name="plex_playlist_items")
op.drop_table("plex_playlist_items")
op.drop_index("ix_plex_playlists_user_id", table_name="plex_playlists")
op.drop_table("plex_playlists")

View file

@ -1109,3 +1109,33 @@ class PlexState(Base, UpdatedAtMixin):
synced_to_plex: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for
users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a
playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later
phase (`plex_rating_key` set once pushed back to the owner's Plex account)."""
__tablename__ = "plex_playlists"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(512))
plex_rating_key: Mapped[str | None] = mapped_column(String(32))
class PlexPlaylistItem(Base):
"""One ordered entry in a playlist. `position` (0-based) orders playback."""
__tablename__ = "plex_playlist_items"
__table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
playlist_id: Mapped[int] = mapped_column(
ForeignKey("plex_playlists.id", ondelete="CASCADE"), index=True
)
item_id: Mapped[int] = mapped_column(
ForeignKey("plex_items.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)

View file

@ -25,7 +25,17 @@ from app import sysconfig
from app.auth import current_user, resolved_user_id
from app.config import settings
from app.db import SessionLocal, get_db
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.models import (
PlexCollection,
PlexItem,
PlexLibrary,
PlexPlaylist,
PlexPlaylistItem,
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.plex import paths as plex_paths
from app.plex import stream as plex_stream
from app.plex import sync as plex_sync
@ -176,6 +186,15 @@ def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
}
def _leaf_card(it: PlexItem, st: PlexState | None, show_title: str | None = None) -> dict:
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds."""
if it.kind == "episode":
card = _episode_card(it, st)
card["show_title"] = show_title
return card
return _movie_card(it, st)
def _csv(v: str | None) -> list[str]:
return [s.strip() for s in v.split(",") if s.strip()] if v else []
@ -591,6 +610,159 @@ def collection_set_editable(
return _collection_card(col)
# --- Playlists (Siftlode-native, per-user, ordered — no Plex account needed; Plex sync is a later phase).
def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist:
pl = db.query(PlexPlaylist).filter_by(id=pid, user_id=user.id).first()
if pl is None:
raise HTTPException(status_code=404, detail="Playlist not found")
return pl
def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict:
return {
"id": pl.id,
"title": pl.title,
"item_count": count,
"thumb": f"/api/plex/image/{thumb_rk}" if thumb_rk else None,
}
def _playlist_item_query(db: Session, pid: int):
return (
db.query(PlexPlaylistItem, PlexItem)
.join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id)
.filter(PlexPlaylistItem.playlist_id == pid)
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
)
@router.get("/playlists")
def playlists(
contains: str | None = None,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The current user's playlists (newest-touched first), each with an item count + a cover thumb.
`contains`=an item rating_key adds `has_item` per playlist (for the 'add to playlist' dialog)."""
contains_id = None
if contains:
row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first()
contains_id = row[0] if row else None
out = []
for pl in db.query(PlexPlaylist).filter_by(user_id=user.id).order_by(PlexPlaylist.updated_at.desc()):
count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0
first = _playlist_item_query(db, pl.id).first()
card = _playlist_card(pl, count, first[1].rating_key if first else None)
if contains_id is not None:
card["has_item"] = (
db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first()
is not None
)
out.append(card)
return {"playlists": out}
@router.post("/playlists")
def create_playlist(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
title = (payload.get("title") or "").strip()
if not title:
raise HTTPException(status_code=422, detail="title is required")
pl = PlexPlaylist(user_id=user.id, title=title)
db.add(pl)
db.flush()
seed = str(payload.get("item_rating_key") or "")
count = 0
thumb_rk = None
if seed:
it = db.query(PlexItem).filter_by(rating_key=seed).first()
if it is not None:
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0))
count, thumb_rk = 1, it.rating_key
db.commit()
return _playlist_card(pl, count, thumb_rk)
@router.get("/playlists/{pid}")
def playlist_detail(pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
pl = _own_playlist_or_404(db, pid, user)
rows = _playlist_item_query(db, pid).all()
items = [it for (_pi, it) in rows]
states = {
s.item_id: s
for s in db.query(PlexState).filter(
PlexState.user_id == user.id, PlexState.item_id.in_([it.id for it in items] or [0])
)
}
shows = {
sh.id: sh.title
for sh in db.query(PlexShow).filter(PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]))
}
cards = [_leaf_card(it, states.get(it.id), shows.get(it.show_id)) for it in items]
return {"id": pl.id, "title": pl.title, "items": cards}
@router.patch("/playlists/{pid}")
def rename_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
pl = _own_playlist_or_404(db, pid, user)
title = (payload.get("title") or "").strip()
if not title:
raise HTTPException(status_code=422, detail="title is required")
pl.title = title
db.commit()
count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0
first = _playlist_item_query(db, pl.id).first()
return _playlist_card(pl, count, first[1].rating_key if first else None)
@router.delete("/playlists/{pid}")
def delete_playlist(pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
pl = _own_playlist_or_404(db, pid, user)
db.delete(pl)
db.commit()
return {"deleted": pid}
@router.post("/playlists/{pid}/items")
def playlist_add_item(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
pl = _own_playlist_or_404(db, pid, user)
it = db.query(PlexItem).filter_by(rating_key=str(payload.get("item_rating_key") or "")).first()
if it is None:
raise HTTPException(status_code=404, detail="Unknown item")
exists = db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first()
if exists is None:
maxpos = db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar()
db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1))
pl.updated_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
@router.delete("/playlists/{pid}/items/{item_rating_key}")
def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
pl = _own_playlist_or_404(db, pid, user)
it = db.query(PlexItem).filter_by(rating_key=str(item_rating_key)).first()
if it is not None:
db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).delete()
pl.updated_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
@router.put("/playlists/{pid}/order")
def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
pl = _own_playlist_or_404(db, pid, user)
order = [str(k) for k in (payload.get("item_rating_keys") or [])]
id_by_rk = {it.rating_key: it.id for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order or [""]))}
for pos, rk in enumerate(order):
iid = id_by_rk.get(rk)
if iid is not None:
db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update({"position": pos})
pl.updated_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
@router.get("/show/{rating_key}")
def show_detail(
rating_key: str,

View file

@ -249,6 +249,7 @@ export default function App() {
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
const plexFilters = useMemo<PlexFilters>(() => {
@ -741,6 +742,7 @@ export default function App() {
setSort={setPlexSort}
filters={plexFilters}
setFilters={setPlexFilters}
onOpenPlaylist={setPlexPlaylistOpen}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/>
@ -824,6 +826,8 @@ export default function App() {
sort={plexSort}
filters={plexFilters}
setFilters={setPlexFilters}
openPlaylist={plexPlaylistOpen}
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
/>
) : (
<Feed

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}

View file

@ -151,5 +151,27 @@
"direct": "Spielt direkt im Browser",
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
"transcode": "Braucht Transcoding (aufwendiger)"
},
"playlistAdd": {
"manage": "Zur Playlist",
"title": "Zur Playlist — {{title}}",
"newPlaceholder": "Name der neuen Playlist…",
"create": "Erstellen",
"none": "Noch keine Playlists. Erstelle oben eine.",
"count": "{{count}} Titel"
},
"playlist": {
"section": "Playlists",
"newPlaceholder": "Neue Playlist…",
"create": "Playlist erstellen",
"none": "Noch keine Playlists.",
"deleteConfirm": "Playlist „{{title}}“ löschen?",
"rename": "Umbenennen",
"playAll": "Alle abspielen",
"delete": "Playlist löschen",
"empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.",
"up": "Nach oben",
"down": "Nach unten",
"remove": "Entfernen"
}
}

View file

@ -151,5 +151,27 @@
"direct": "Plays directly in the browser",
"remux": "Needs a light remux (no video re-encode)",
"transcode": "Needs transcoding (heavier)"
},
"playlistAdd": {
"manage": "Add to playlist",
"title": "Add to playlist — {{title}}",
"newPlaceholder": "New playlist name…",
"create": "Create",
"none": "No playlists yet. Create one above.",
"count": "{{count}} items"
},
"playlist": {
"section": "Playlists",
"newPlaceholder": "New playlist…",
"create": "Create playlist",
"none": "No playlists yet.",
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
"rename": "Rename",
"playAll": "Play all",
"delete": "Delete playlist",
"empty": "This playlist is empty. Add titles from their info page.",
"up": "Move up",
"down": "Move down",
"remove": "Remove"
}
}

View file

@ -151,5 +151,27 @@
"direct": "Közvetlenül játszható a böngészőben",
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
"transcode": "Transcode kell (nehezebb)"
},
"playlistAdd": {
"manage": "Listához adás",
"title": "Listához adás — {{title}}",
"newPlaceholder": "Új lista neve…",
"create": "Létrehozás",
"none": "Még nincs lista. Hozz létre egyet fent.",
"count": "{{count}} elem"
},
"playlist": {
"section": "Lejátszási listák",
"newPlaceholder": "Új lista…",
"create": "Lista létrehozása",
"none": "Még nincs lista.",
"deleteConfirm": "Törlöd a(z) „{{title}}” listát?",
"rename": "Átnevezés",
"playAll": "Összes lejátszása",
"delete": "Lista törlése",
"empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.",
"up": "Fel",
"down": "Le",
"remove": "Eltávolítás"
}
}

View file

@ -652,6 +652,7 @@ export interface PlexCard {
season_count?: number | null; // show
season_number?: number | null; // episode
episode_number?: number | null; // episode
show_title?: string | null; // episode (in a mixed playlist)
summary?: string | null; // episode
}
export interface PlexBrowseResult {
@ -730,6 +731,18 @@ export interface PlexCollection {
editable: boolean;
can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
}
export interface PlexPlaylist {
id: number;
title: string;
item_count: number;
thumb?: string | null;
has_item?: boolean; // only when the list was fetched with ?contains=<rating_key>
}
export interface PlexPlaylistDetail {
id: number;
title: string;
items: PlexCard[];
}
export interface PlexCastMember {
name: string;
role?: string | null;
@ -1164,6 +1177,22 @@ export const api = {
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
// --- Playlists (Siftlode-native, per-user, ordered) ---
plexPlaylists: (contains?: string): Promise<{ playlists: PlexPlaylist[] }> =>
req(`/api/plex/playlists${contains ? `?contains=${encodeURIComponent(contains)}` : ""}`),
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }),
plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }),
plexDeletePlaylist: (id: number): Promise<{ deleted: number }> =>
req(`/api/plex/playlists/${id}`, { method: "DELETE" }),
plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ item_rating_key: itemRk }) }),
plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
plexShow: (id: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
plexItem: (id: string): Promise<PlexItemDetail> =>

View file

@ -14,6 +14,15 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.30.0",
date: "2026-07-06",
summary: "Plex playlists — your own ordered watch-lists.",
features: [
"Plex playlists: create your own ordered lists of films (each user has their own — no Plex account needed). Add a film to a playlist from its info page (\"Add to playlist\"), then find your playlists in the Plex sidebar.",
"Plex playlists: open a playlist to reorder (up/down), remove items, rename or delete it, and hit \"Play all\" to watch straight through — the player steps through the whole list in order, auto-advancing to the next.",
],
},
{
version: "0.29.0",
date: "2026-07-06",