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:
parent
1fd3003038
commit
25197ed817
16 changed files with 796 additions and 18 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue