From 25197ed817d63d3e8ed6bac7b92ecf04d12046ac Mon Sep 17 00:00:00 2001
From: npeter83
Date: Mon, 6 Jul 2026 19:05:12 +0200
Subject: [PATCH] =?UTF-8?q?feat(plex):=20Playlists=20Phase=201=20=E2=80=94?=
=?UTF-8?q?=20per-user=20ordered=20watch-lists=20(Siftlode-native)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
VERSION | 2 +-
.../alembic/versions/0048_plex_playlists.py | 58 ++++++
backend/app/models.py | 30 +++
backend/app/routes/plex.py | 174 ++++++++++++++++-
frontend/src/App.tsx | 4 +
frontend/src/components/PlexBrowse.tsx | 45 ++++-
frontend/src/components/PlexInfo.tsx | 15 ++
frontend/src/components/PlexPlayer.tsx | 27 ++-
frontend/src/components/PlexPlaylistAdd.tsx | 115 +++++++++++
frontend/src/components/PlexPlaylistView.tsx | 184 ++++++++++++++++++
frontend/src/components/PlexSidebar.tsx | 56 +++++-
frontend/src/i18n/locales/de/plex.json | 22 +++
frontend/src/i18n/locales/en/plex.json | 22 +++
frontend/src/i18n/locales/hu/plex.json | 22 +++
frontend/src/lib/api.ts | 29 +++
frontend/src/lib/releaseNotes.ts | 9 +
16 files changed, 796 insertions(+), 18 deletions(-)
create mode 100644 backend/alembic/versions/0048_plex_playlists.py
create mode 100644 frontend/src/components/PlexPlaylistAdd.tsx
create mode 100644 frontend/src/components/PlexPlaylistView.tsx
diff --git a/VERSION b/VERSION
index ae6dd4e..c25c8e5 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.29.0
+0.30.0
diff --git a/backend/alembic/versions/0048_plex_playlists.py b/backend/alembic/versions/0048_plex_playlists.py
new file mode 100644
index 0000000..68097cb
--- /dev/null
+++ b/backend/alembic/versions/0048_plex_playlists.py
@@ -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")
diff --git a/backend/app/models.py b/backend/app/models.py
index 354114e..c3a9ace 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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)
diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py
index a3673c4..0fd000b 100644
--- a/backend/app/routes/plex.py
+++ b/backend/app/routes/plex.py
@@ -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,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 67401b3..127844e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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(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(() => {
@@ -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)}
/>
) : (
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({ 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 (
}>
-
+
+
+ );
+ }
+ if (sub.view.kind === "playlist") {
+ const pid = sub.view.id;
+ return (
+ {t("plex.loading")}
}>
+ sub.open({ kind: "player", id: itemRk, queue })}
+ />
);
}
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 9193f63..dd1ca07 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -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(null);
const custMenuRef = useRef(null);
@@ -367,6 +370,15 @@ export default function PlexInfo({
{t("plex.info.clearResume")}
)}
+ {detail.kind === "movie" && (
+
+ )}
{isAdmin && detail.kind === "movie" && library && (