Verified each deferred per-subsystem item against current source, then fixed the
real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout):
- search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment
failure/omitted rows) — restores the scheduler's enriched_at guard so a real
Short can't leak into the feed and never get reclassified.
- downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets
(they carry no TTL, so the expiry passes never cleared them).
- downloads B6: quota/edit errors now return a structured {code,reason,limit}
the trilingual client localises, instead of hardcoded English + dot-decimal GB.
- channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update)
instead of 404ing when the admin isn't personally subscribed.
- channels BB2: enrich the stub on BOTH the normal and "already exists" desync
path (nest the insert try inside the client `with`).
- channels CB3: drop the redundant second _discovery_rows read (identity-mapped
rows are already enriched; re-sort in Python for the title tie-break).
- playlists PC1: read the live playlist once in push() and share it with plan_push
+ push_playlist (halves read-quota, closes the second TOCTOU window).
- playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1);
rename combines count+duration into one aggregate + a single cover lookup.
- messages MB2: get_thread only resolves a messageable partner OR one we already
share a thread with (closes the user-enumeration oracle).
- messages MB3: push a live unread-count to the user's other tabs after mark-read.
- messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of
loading the whole message history into memory.
- config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked
to disable them rather than snapping back to the env default.
528 lines
18 KiB
Python
528 lines
18 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 _serialize, feed_columns
|
|
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 _next_playlist_position(db: Session, user_id: int) -> int:
|
|
"""Append position for a new playlist in this user's rail."""
|
|
return (
|
|
db.scalar(
|
|
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
|
Playlist.user_id == user_id
|
|
)
|
|
)
|
|
+ 1
|
|
)
|
|
|
|
|
|
def _next_item_position(db: Session, playlist_id: int) -> int:
|
|
"""Append position for a new item at the end of a playlist."""
|
|
return (
|
|
db.scalar(
|
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
|
PlaylistItem.playlist_id == playlist_id
|
|
)
|
|
)
|
|
+ 1
|
|
)
|
|
|
|
|
|
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
|
"""Append a video to a playlist if not already present (idempotent). Returns whether it
|
|
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
|
exists = db.scalar(
|
|
select(PlaylistItem).where(
|
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
)
|
|
)
|
|
if exists is not None:
|
|
return False
|
|
db.add(
|
|
PlaylistItem(
|
|
playlist_id=pl.id, video_id=video_id, position=_next_item_position(db, pl.id)
|
|
)
|
|
)
|
|
if mark_dirty:
|
|
recompute_dirty(db, pl)
|
|
db.commit()
|
|
return True
|
|
|
|
|
|
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
|
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
|
|
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
|
item = db.scalar(
|
|
select(PlaylistItem).where(
|
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
)
|
|
)
|
|
if item is None:
|
|
return False
|
|
db.delete(item)
|
|
if mark_dirty:
|
|
recompute_dirty(db, pl)
|
|
db.commit()
|
|
return True
|
|
|
|
|
|
def _summary(
|
|
pl: Playlist,
|
|
count: int,
|
|
total_duration: int = 0,
|
|
has_video: bool | None = None,
|
|
cover: str | None = None,
|
|
) -> dict:
|
|
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
|
|
|
|
|
|
@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()
|
|
)
|
|
# Batch the cover thumbnails (first item per playlist by position) in ONE window query
|
|
# instead of a per-playlist SELECT inside _summary (the old N+1).
|
|
rn = func.row_number().over(
|
|
partition_by=PlaylistItem.playlist_id,
|
|
order_by=PlaylistItem.position,
|
|
).label("rn")
|
|
first_items = (
|
|
select(
|
|
PlaylistItem.playlist_id.label("pid"),
|
|
Video.thumbnail_url.label("thumb"),
|
|
rn,
|
|
)
|
|
.join(Video, Video.id == PlaylistItem.video_id)
|
|
.where(PlaylistItem.playlist_id.in_(ids))
|
|
.subquery()
|
|
)
|
|
covers = dict(
|
|
db.execute(
|
|
select(first_items.c.pid, first_items.c.thumb).where(first_items.c.rn == 1)
|
|
).all()
|
|
)
|
|
return [
|
|
_summary(
|
|
p,
|
|
counts.get(p.id, 0),
|
|
durations.get(p.id, 0),
|
|
(p.id in member) if contains else None,
|
|
covers.get(p.id),
|
|
)
|
|
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")
|
|
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
|
|
db.add(pl)
|
|
db.commit()
|
|
return _summary(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:
|
|
pl = Playlist(
|
|
user_id=user.id,
|
|
name="Watch later",
|
|
kind="watch_later",
|
|
position=_next_playlist_position(db, user.id),
|
|
)
|
|
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)
|
|
_add_item(db, pl, video_id, mark_dirty=False)
|
|
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:
|
|
_remove_item(db, pl, video_id, mark_dirty=False)
|
|
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, quota.QuotaAction.PLAYLISTS_PULL):
|
|
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, quota.QuotaAction.PLAYLISTS_PULL):
|
|
return repull_playlist(db, user, pl)
|
|
except YouTubeError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=422, 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, quota.QuotaAction.PLAYLISTS_PUSH):
|
|
plan = plan_push(db, user, pl)
|
|
except YouTubeError:
|
|
raise HTTPException(status_code=422, 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, quota.QuotaAction.PLAYLISTS_PUSH):
|
|
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
|
# plan and the push, halving read-quota and closing the second read→write TOCTOU
|
|
# window. A fresh export (no yt_playlist_id) reads nothing.
|
|
live = None
|
|
if pl.yt_playlist_id:
|
|
with YouTubeClient(db, user) as yt:
|
|
live = (
|
|
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
|
yt.get_playlist_snippet(pl.yt_playlist_id),
|
|
)
|
|
plan = plan_push(db, user, pl, live=live)
|
|
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, live=live)
|
|
except YouTubeError:
|
|
db.rollback()
|
|
raise HTTPException(status_code=422, 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(*feed_columns(user, state))
|
|
.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()
|
|
# One aggregate for count + duration (outer join so an empty playlist still returns 0),
|
|
# plus a single cover lookup — was 3 separate round-trips.
|
|
count, total = db.execute(
|
|
select(
|
|
func.count(PlaylistItem.id),
|
|
func.coalesce(func.sum(Video.duration_seconds), 0),
|
|
)
|
|
.select_from(PlaylistItem)
|
|
.outerjoin(Video, Video.id == PlaylistItem.video_id)
|
|
.where(PlaylistItem.playlist_id == pl.id)
|
|
).one()
|
|
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)
|
|
)
|
|
return _summary(pl, count or 0, total or 0, cover=cover)
|
|
|
|
|
|
@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, quota.QuotaAction.PLAYLISTS_DELETE):
|
|
with YouTubeClient(db, user) as yt:
|
|
yt.delete_playlist(pl.yt_playlist_id)
|
|
except YouTubeError:
|
|
raise HTTPException(status_code=422, 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")
|
|
_add_item(db, pl, video_id, mark_dirty=True)
|
|
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)
|
|
_remove_item(db, pl, video_id, mark_dirty=True)
|
|
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
|
|
seen: set[str] = set()
|
|
for vid in order:
|
|
it = items.get(vid)
|
|
if it is not None:
|
|
it.position = pos
|
|
pos += 1
|
|
seen.add(vid)
|
|
# Any items the payload omitted (e.g. added concurrently in another tab) keep their relative
|
|
# order but get fresh trailing positions, so no two items collide on the same position.
|
|
for it in sorted(
|
|
(it for vid, it in items.items() if vid not in seen), key=lambda i: i.position
|
|
):
|
|
it.position = pos
|
|
pos += 1
|
|
recompute_dirty(db, pl)
|
|
db.commit()
|
|
return {"playlist_id": pl.id, "count": pos}
|