From bf769379cbeaaa379b54ea533e2db33567a04c78 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 14:37:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(playlists):=20backend=20foundation=20?= =?UTF-8?q?=E2=80=94=20model,=20migration,=20CRUD=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-user local playlists: Playlist + PlaylistItem models (with forward-looking kind/source/yt_playlist_id/dirty columns for the later YouTube-sync phases) and migration 0011. New /api/playlists routes: list (with optional contains= membership flags for the add-to-playlist popover), create, get detail (items serialized via the feed serializer, with per-user watch state), rename, delete, add/remove item, and reorder. Watch later (built-in) is protected from deletion. --- backend/alembic/versions/0011_playlists.py | 88 +++++++ backend/app/main.py | 3 +- backend/app/models.py | 61 +++++ backend/app/routes/playlists.py | 261 +++++++++++++++++++++ 4 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0011_playlists.py create mode 100644 backend/app/routes/playlists.py diff --git a/backend/alembic/versions/0011_playlists.py b/backend/alembic/versions/0011_playlists.py new file mode 100644 index 0000000..efd5c05 --- /dev/null +++ b/backend/alembic/versions/0011_playlists.py @@ -0,0 +1,88 @@ +"""local playlists: playlists + playlist_items + +Revision ID: 0011_playlists +Revises: 0010_watch_progress +Create Date: 2026-06-15 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0011_playlists" +down_revision: Union[str, None] = "0010_watch_progress" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "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("name", sa.String(length=255), nullable=False), + sa.Column( + "kind", sa.String(length=16), nullable=False, server_default="user" + ), + sa.Column( + "source", sa.String(length=16), nullable=False, server_default="local" + ), + sa.Column("yt_playlist_id", sa.String(length=64), nullable=True), + sa.Column( + "dirty", sa.Boolean(), nullable=False, server_default="false" + ), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_playlists_user_id", "playlists", ["user_id"]) + + op.create_table( + "playlist_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "playlist_id", + sa.Integer(), + sa.ForeignKey("playlists.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "video_id", + sa.String(length=16), + sa.ForeignKey("videos.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "added_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"), + ) + op.create_index("ix_playlist_items_playlist_id", "playlist_items", ["playlist_id"]) + op.create_index("ix_playlist_items_video_id", "playlist_items", ["video_id"]) + + +def downgrade() -> None: + op.drop_index("ix_playlist_items_video_id", table_name="playlist_items") + op.drop_index("ix_playlist_items_playlist_id", table_name="playlist_items") + op.drop_table("playlist_items") + op.drop_index("ix_playlists_user_id", table_name="playlists") + op.drop_table("playlists") diff --git a/backend/app/main.py b/backend/app/main.py index cb07a68..34fef9e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware from app import auth from app.config import settings -from app.routes import admin, channels, feed, health, me, quota, sync, tags, version +from app.routes import admin, channels, feed, health, me, playlists, quota, sync, tags, version from app.scheduler import shutdown_scheduler, start_scheduler @@ -66,6 +66,7 @@ app.include_router(tags.router) app.include_router(feed.router) app.include_router(me.router) app.include_router(channels.router) +app.include_router(playlists.router) app.include_router(admin.router) app.include_router(quota.router) app.include_router(version.router) diff --git a/backend/app/models.py b/backend/app/models.py index 46bb24c..546e645 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -301,3 +301,64 @@ class AppState(Base): sync_paused: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) + + +class Playlist(Base): + """A per-user named, ordered collection of videos from the shared catalog. + + `kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user). + `source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube + playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id. + `dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read + sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT + sync phases; the foundation phase only uses local playlists.)""" + + __tablename__ = "playlists" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user") + source: Mapped[str] = mapped_column( + String(16), default="local", server_default="local" + ) + yt_playlist_id: Mapped[str | None] = mapped_column(String(64)) + dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + items: Mapped[list["PlaylistItem"]] = relationship( + back_populates="playlist", + cascade="all, delete-orphan", + order_by="PlaylistItem.position", + ) + + +class PlaylistItem(Base): + """A video's membership in a playlist, with its order within that playlist.""" + + __tablename__ = "playlist_items" + __table_args__ = ( + UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + playlist_id: Mapped[int] = mapped_column( + ForeignKey("playlists.id", ondelete="CASCADE"), index=True + ) + video_id: Mapped[str] = mapped_column( + ForeignKey("videos.id", ondelete="CASCADE"), index=True + ) + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + added_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + playlist: Mapped["Playlist"] = relationship(back_populates="items") diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py new file mode 100644 index 0000000..ba90de3 --- /dev/null +++ b/backend/app/routes/playlists.py @@ -0,0 +1,261 @@ +"""Local playlists: per-user named, ordered collections of videos from the shared +catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder +items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select, and_ +from sqlalchemy.orm import Session, aliased + +from app.auth import current_user +from app.db import get_db +from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState +from app.routes.feed import _serialize + +router = APIRouter(prefix="/api/playlists", tags=["playlists"]) + + +def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: + pl = db.get(Playlist, playlist_id) + if pl is None or pl.user_id != user.id: + raise HTTPException(status_code=404, detail="Unknown playlist") + return pl + + +def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict: + cover = db.scalar( + select(Video.thumbnail_url) + .join(PlaylistItem, PlaylistItem.video_id == Video.id) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + .limit(1) + ) + out = { + "id": pl.id, + "name": pl.name, + "kind": pl.kind, + "source": pl.source, + "yt_playlist_id": pl.yt_playlist_id, + "item_count": count, + "cover_thumbnail": cover, + } + if has_video is not None: + out["has_video"] = has_video + return out + + +@router.get("") +def list_playlists( + contains: str | None = None, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> list[dict]: + """The user's playlists (newest custom order). With `contains=`, each entry + also reports whether that video is already in it — used by the add-to-playlist popover.""" + pls = ( + db.execute( + select(Playlist) + .where(Playlist.user_id == user.id) + .order_by(Playlist.position, Playlist.created_at) + ) + .scalars() + .all() + ) + if not pls: + return [] + ids = [p.id for p in pls] + counts = dict( + db.execute( + select(PlaylistItem.playlist_id, func.count()) + .where(PlaylistItem.playlist_id.in_(ids)) + .group_by(PlaylistItem.playlist_id) + ).all() + ) + member: set[int] = set() + if contains: + member = set( + db.execute( + select(PlaylistItem.playlist_id).where( + PlaylistItem.playlist_id.in_(ids), + PlaylistItem.video_id == contains, + ) + ) + .scalars() + .all() + ) + return [ + _summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None) + for p in pls + ] + + +@router.post("") +def create_playlist( + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + next_pos = ( + db.scalar( + select(func.coalesce(func.max(Playlist.position), -1)).where( + Playlist.user_id == user.id + ) + ) + + 1 + ) + pl = Playlist(user_id=user.id, name=name, position=next_pos) + db.add(pl) + db.commit() + return _summary(db, pl, 0) + + +@router.get("/{playlist_id}") +def get_playlist( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + state = aliased(VideoState) + rows = db.execute( + select( + Video.id, + Video.title, + Video.channel_id, + Channel.title.label("channel_title"), + Channel.thumbnail_url.label("channel_thumbnail"), + Channel.handle.label("channel_handle"), + Video.published_at, + Video.thumbnail_url, + Video.duration_seconds, + Video.view_count, + Video.is_short, + Video.live_status, + func.coalesce(state.status, "new").label("status"), + func.coalesce(state.position_seconds, 0).label("position_seconds"), + ) + .join(PlaylistItem, PlaylistItem.video_id == Video.id) + .join(Channel, Channel.id == Video.channel_id) + .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id)) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + ).all() + return { + "id": pl.id, + "name": pl.name, + "kind": pl.kind, + "source": pl.source, + "yt_playlist_id": pl.yt_playlist_id, + "items": [_serialize(r) for r in rows], + } + + +@router.patch("/{playlist_id}") +def rename_playlist( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + if "name" in payload: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name cannot be empty") + pl.name = name + db.commit() + count = db.scalar( + select(func.count()).where(PlaylistItem.playlist_id == pl.id) + ) + return _summary(db, pl, count or 0) + + +@router.delete("/{playlist_id}") +def delete_playlist( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + if pl.kind == "watch_later": + raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.") + db.delete(pl) # items cascade + db.commit() + return {"deleted": playlist_id} + + +@router.post("/{playlist_id}/items") +def add_item( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + video_id = payload.get("video_id") + if not video_id or db.get(Video, video_id) is None: + raise HTTPException(status_code=404, detail="Unknown video") + exists = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if exists is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(PlaylistItem.position), -1)).where( + PlaylistItem.playlist_id == pl.id + ) + ) + + 1 + ) + db.add( + PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) + ) + db.commit() + return {"playlist_id": pl.id, "video_id": video_id} + + +@router.delete("/{playlist_id}/items/{video_id}") +def remove_item( + playlist_id: int, + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + item = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if item is not None: + db.delete(item) + db.commit() + return {"playlist_id": pl.id, "video_id": video_id} + + +@router.put("/{playlist_id}/order") +def reorder_items( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + order = payload.get("video_ids") or [] + items = { + it.video_id: it + for it in db.execute( + select(PlaylistItem).where(PlaylistItem.playlist_id == pl.id) + ).scalars() + } + pos = 0 + for vid in order: + it = items.get(vid) + if it is not None: + it.position = pos + pos += 1 + db.commit() + return {"playlist_id": pl.id, "count": pos}