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

@ -125,6 +125,49 @@ class YouTubeClient:
if not page_token:
break
def iter_my_playlists(self) -> Iterator[dict]:
"""Yield the authenticated user's own playlists (OAuth, so private ones are
included). Note: YouTube's special Watch Later / History playlists are NOT
returned by the Data API and cannot be synced."""
page_token = None
while True:
params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50}
if page_token:
params["pageToken"] = page_token
data = self._get("playlists", params, cost=1, allow_key=False)
for item in data.get("items", []):
sn = item.get("snippet", {})
yield {
"id": item.get("id"),
"title": sn.get("title"),
"item_count": item.get("contentDetails", {}).get("itemCount"),
"thumbnail_url": best_thumbnail(sn.get("thumbnails")),
}
page_token = data.get("nextPageToken")
if not page_token:
break
def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]:
"""Yield the video ids of one of the user's own playlists, in playlist order
(OAuth, so private/unlisted playlists work)."""
page_token = None
while True:
params = {
"part": "contentDetails",
"playlistId": playlist_id,
"maxResults": 50,
}
if page_token:
params["pageToken"] = page_token
data = self._get("playlistItems", params, cost=1, allow_key=False)
for item in data.get("items", []):
vid = item.get("contentDetails", {}).get("videoId")
if vid:
yield vid
page_token = data.get("nextPageToken")
if not page_token:
break
def get_channels(self, channel_ids: list[str]) -> list[dict]:
items: list[dict] = []
for batch in _chunks(channel_ids, 50):