Store a SHA-256 of each playlist's name + ordered video ids as last synced from / pushed to YouTube (migration 0013, backfilled for already-clean linked playlists). 'dirty' is now recomputed on every edit by comparing the current fingerprint to that baseline instead of being stickily set true. So manually restoring the original order (or undo back to it) clears dirty on its own — the Reset to YouTube button disappears and no YouTube read is needed. Baseline is set on read-sync, repull (reset), and a clean push; a missing baseline counts as dirty (unknown YT state). Verified the migration's fingerprint matches the app's runtime fingerprint exactly.
507 lines
16 KiB
Python
507 lines
16 KiB
Python
"""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 import quota
|
|
from app.auth import current_user, has_read_scope, has_write_scope
|
|
from app.db import get_db
|
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
|
from app.routes.feed import _saved_expr, _serialize
|
|
from app.sync.playlists import (
|
|
plan_push,
|
|
push_playlist,
|
|
recompute_dirty,
|
|
repull_playlist,
|
|
sync_user_playlists,
|
|
)
|
|
from app.youtube.client import YouTubeClient, YouTubeError
|
|
|
|
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,
|
|
total_duration: int = 0,
|
|
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,
|
|
"dirty": pl.dirty,
|
|
"item_count": count,
|
|
"total_duration_seconds": total_duration,
|
|
"cover_thumbnail": cover,
|
|
}
|
|
if has_video is not None:
|
|
out["has_video"] = has_video
|
|
return out
|
|
|
|
|
|
def _total_duration(db: Session, playlist_id: int) -> int:
|
|
return (
|
|
db.scalar(
|
|
select(func.coalesce(func.sum(Video.duration_seconds), 0))
|
|
.select_from(PlaylistItem)
|
|
.join(Video, Video.id == PlaylistItem.video_id)
|
|
.where(PlaylistItem.playlist_id == playlist_id)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
|
|
@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()
|
|
)
|
|
durations = dict(
|
|
db.execute(
|
|
select(
|
|
PlaylistItem.playlist_id,
|
|
func.coalesce(func.sum(Video.duration_seconds), 0),
|
|
)
|
|
.join(Video, Video.id == PlaylistItem.video_id)
|
|
.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),
|
|
durations.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)
|
|
|
|
|
|
def _watch_later(db: Session, user: User) -> Playlist:
|
|
"""The user's built-in Watch later playlist, created on first use."""
|
|
pl = db.scalar(
|
|
select(Playlist).where(
|
|
Playlist.user_id == user.id, Playlist.kind == "watch_later"
|
|
)
|
|
)
|
|
if pl is None:
|
|
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="Watch later", kind="watch_later", position=next_pos
|
|
)
|
|
db.add(pl)
|
|
db.commit()
|
|
return pl
|
|
|
|
|
|
@router.post("/watch-later")
|
|
def add_watch_later(
|
|
payload: dict,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Bookmark shortcut: add a video to the built-in Watch later playlist."""
|
|
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")
|
|
pl = _watch_later(db, user)
|
|
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 {"saved": True, "playlist_id": pl.id}
|
|
|
|
|
|
@router.delete("/watch-later/{video_id}")
|
|
def remove_watch_later(
|
|
video_id: str,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
pl = db.scalar(
|
|
select(Playlist).where(
|
|
Playlist.user_id == user.id, Playlist.kind == "watch_later"
|
|
)
|
|
)
|
|
if pl is not None:
|
|
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 {"saved": False}
|
|
|
|
|
|
@router.post("/sync-youtube")
|
|
def sync_youtube(
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Pull the user's YouTube playlists into local mirrors now (read-only direction).
|
|
Also runs automatically on the scheduler; this is the manual 'sync now'."""
|
|
if not has_read_scope(user):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Connect YouTube (read access) to sync your playlists.",
|
|
)
|
|
with quota.attribute(user.id, "playlist_sync"):
|
|
return sync_user_playlists(db, user)
|
|
|
|
|
|
@router.post("/{playlist_id}/revert-youtube")
|
|
def revert_youtube(
|
|
playlist_id: int,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Reset one YouTube-linked playlist to its current YouTube state, discarding local
|
|
edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list
|
|
once the in-session undo history is gone."""
|
|
pl = _own_playlist(db, user, playlist_id)
|
|
if not pl.yt_playlist_id:
|
|
raise HTTPException(status_code=400, detail="This playlist isn't linked to YouTube.")
|
|
if not has_read_scope(user):
|
|
raise HTTPException(
|
|
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
|
|
)
|
|
try:
|
|
with quota.attribute(user.id, "playlist_sync"):
|
|
return repull_playlist(db, user, pl)
|
|
except YouTubeError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=502, detail="Couldn't refresh from YouTube.")
|
|
|
|
|
|
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
|
"""A local playlist the user may push to YouTube (create or update). The built-in
|
|
Watch later and read-only YouTube mirrors are never pushable."""
|
|
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 sent to YouTube."
|
|
)
|
|
return pl
|
|
|
|
|
|
@router.get("/{playlist_id}/push-plan")
|
|
def push_plan(
|
|
playlist_id: int,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a
|
|
confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page)."""
|
|
pl = _pushable(db, user, playlist_id)
|
|
if not has_write_scope(user):
|
|
raise HTTPException(
|
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
|
)
|
|
try:
|
|
with quota.attribute(user.id, "playlist_push"):
|
|
plan = plan_push(db, user, pl)
|
|
except YouTubeError:
|
|
raise HTTPException(status_code=502, detail="Couldn't read the playlist on YouTube.")
|
|
plan["remaining_today"] = quota.remaining_today(db)
|
|
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
|
return plan
|
|
|
|
|
|
@router.post("/{playlist_id}/push")
|
|
def push(
|
|
playlist_id: int,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Push a local playlist to YouTube (create it on first push, else reconcile to match
|
|
local — local wins). Refuses if the estimated quota exceeds what's left today, so a
|
|
big playlist can't strand half-pushed."""
|
|
pl = _pushable(db, user, playlist_id)
|
|
if not has_write_scope(user):
|
|
raise HTTPException(
|
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
|
)
|
|
try:
|
|
with quota.attribute(user.id, "playlist_push"):
|
|
plan = plan_push(db, user, pl)
|
|
if plan["units_estimate"] > quota.remaining_today(db):
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail="Not enough YouTube quota left today for this sync.",
|
|
)
|
|
return push_playlist(db, user, pl)
|
|
except YouTubeError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=502, detail="YouTube playlist sync failed.")
|
|
|
|
|
|
@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"),
|
|
_saved_expr(user),
|
|
)
|
|
.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,
|
|
"dirty": pl.dirty,
|
|
"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")
|
|
if name != pl.name:
|
|
pl.name = name
|
|
recompute_dirty(db, pl)
|
|
db.commit()
|
|
count = db.scalar(
|
|
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
|
|
)
|
|
return _summary(db, pl, count or 0, _total_duration(db, pl.id))
|
|
|
|
|
|
@router.delete("/{playlist_id}")
|
|
def delete_playlist(
|
|
playlist_id: int,
|
|
on_youtube: bool = False,
|
|
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.")
|
|
if on_youtube:
|
|
if not pl.yt_playlist_id:
|
|
raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.")
|
|
if not has_write_scope(user):
|
|
raise HTTPException(
|
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
|
)
|
|
try:
|
|
with quota.attribute(user.id, "playlist_delete"):
|
|
with YouTubeClient(db, user) as yt:
|
|
yt.delete_playlist(pl.yt_playlist_id)
|
|
except YouTubeError:
|
|
raise HTTPException(status_code=502, detail="YouTube delete failed.")
|
|
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)
|
|
)
|
|
recompute_dirty(db, pl)
|
|
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)
|
|
recompute_dirty(db, pl)
|
|
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
|
|
recompute_dirty(db, pl)
|
|
db.commit()
|
|
return {"playlist_id": pl.id, "count": pos}
|