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:
parent
cbf5037374
commit
240b6fb611
5 changed files with 228 additions and 1 deletions
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue