feat(playlists): YouTube playlist read-sync (backend)

Mirror each user's own YouTube playlists into local source='youtube' playlists,
one-way (YT -> local). New client methods iter_my_playlists / iter_my_playlist_video_ids
(OAuth, so private playlists work); sync/playlists.py reconciles the mirror (matching YT
order) and ingests any playlist videos not in the shared catalog yet (with stub channels).
POST /api/playlists/sync-youtube for manual sync (read-scope gated, per-user quota) plus a
scheduler job (playlist_sync_minutes, default 6h) that syncs all read-scope users. YouTube's
Watch Later / History are not API-accessible and are never synced.
This commit is contained in:
npeter83 2026-06-15 19:37:03 +02:00
parent 9c28a75787
commit a661d079ee
5 changed files with 228 additions and 1 deletions

View file

@ -5,10 +5,12 @@ 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 import quota
from app.auth import current_user, has_read_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 sync_user_playlists
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
@ -187,6 +189,22 @@ def remove_watch_later(
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.get("/{playlist_id}")
def get_playlist(
playlist_id: int,