feat(playlists): backend foundation — model, migration, CRUD API
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=<video_id> 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.
This commit is contained in:
parent
dcb1605cc7
commit
bf769379cb
4 changed files with 412 additions and 1 deletions
88
backend/alembic/versions/0011_playlists.py
Normal file
88
backend/alembic/versions/0011_playlists.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app import auth
|
from app import auth
|
||||||
from app.config import settings
|
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
|
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(feed.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
|
app.include_router(playlists.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
|
|
|
||||||
|
|
@ -301,3 +301,64 @@ class AppState(Base):
|
||||||
sync_paused: Mapped[bool] = mapped_column(
|
sync_paused: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false"
|
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")
|
||||||
|
|
|
||||||
261
backend/app/routes/playlists.py
Normal file
261
backend/app/routes/playlists.py
Normal file
|
|
@ -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=<video_id>`, 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}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue