feat(playlists): unify Saved into a built-in Watch later playlist

The old per-video status='saved' becomes membership in a built-in, undeletable
'watch_later' playlist. Migration 0012 moves existing saved videos into each user's
Watch later list and demotes the states to 'new'. The feed/playlist serializers now
expose a 'saved' boolean (EXISTS in watch_later); the card bookmark toggles watch_later
via new /api/playlists/watch-later add/remove endpoints (create-on-demand) instead of
setting a status. Removed the 'saved' show-filter and status. Watch later shows a
localized name and hides rename/delete in the Playlists page and add-to-playlist popover.
This commit is contained in:
npeter83 2026-06-15 15:33:53 +02:00
parent 47bad6d9ce
commit dea740b728
12 changed files with 260 additions and 36 deletions

View file

@ -7,13 +7,24 @@ from sqlalchemy.orm import Session, aliased
from app import quota
from app.auth import current_user
from app.db import get_db
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState
from app.models import (
Channel,
ChannelTag,
Playlist,
PlaylistItem,
Subscription,
Tag,
User,
Video,
VideoState,
)
from app.sync.videos import parse_iso8601_duration
from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api", tags=["feed"])
VALID_STATES = {"new", "watched", "saved", "hidden"}
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
VALID_STATES = {"new", "watched", "hidden"}
HIDDEN_LIVE = ("live", "upcoming")
# Resume-position thresholds (mirror the client): positions below this are "didn't
@ -29,6 +40,23 @@ def _channel_url(channel_id: str, handle: str | None) -> str:
return f"https://www.youtube.com/channel/{channel_id}"
def _saved_expr(user: User):
"""A correlated EXISTS labelled `saved`: is this video in the user's Watch later list?
(Watch later is the built-in playlist that replaced the old per-video 'saved' status.)"""
return (
select(1)
.select_from(PlaylistItem)
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
.where(
Playlist.user_id == user.id,
Playlist.kind == "watch_later",
PlaylistItem.video_id == Video.id,
)
.exists()
.label("saved")
)
def _serialize(row) -> dict:
return {
"id": row.id,
@ -45,6 +73,7 @@ def _serialize(row) -> dict:
"live_status": row.live_status,
"status": row.status or "new",
"position_seconds": row.position_seconds or 0,
"saved": bool(getattr(row, "saved", False)),
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
}
@ -96,6 +125,7 @@ def _filtered_query(
Video.live_status,
status_expr.label("status"),
position_expr.label("position_seconds"),
_saved_expr(user),
).join(Channel, Channel.id == Video.channel_id)
if scope == "all":
@ -176,7 +206,7 @@ def _filtered_query(
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
explicit_view = show in ("watched", "saved", "hidden")
explicit_view = show in ("watched", "hidden")
if not explicit_view:
type_clauses = []
if show_normal:
@ -198,8 +228,6 @@ def _filtered_query(
)
elif show == "watched":
query = query.where(status_expr == "watched")
elif show == "saved":
query = query.where(status_expr == "saved")
elif show == "hidden":
query = query.where(status_expr == "hidden")
else: # all

View file

@ -8,7 +8,7 @@ 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
from app.routes.feed import _saved_expr, _serialize
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
@ -110,6 +110,83 @@ def create_playlist(
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.get("/{playlist_id}")
def get_playlist(
playlist_id: int,
@ -134,6 +211,7 @@ def get_playlist(
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)