Add playlists support and fix explore older videos

- New playlists router: fetch channel playlists from YouTube, index
  playlist videos, browse by playlist with pagination
- Playlist model gets video_ids column to store ordered video list
- Register playlists router in main.py with DB migration
- Add Playlists tab to Channel page: grid of playlist cards, click to
  browse videos, index/re-index per playlist
- Fix explore older videos skipping all entries without published_at;
  flat-playlist entries for older videos rarely include timestamp data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:28:35 +02:00
parent d31fc1ef7f
commit 5b0cf27f07
7 changed files with 448 additions and 6 deletions

View File

@@ -353,6 +353,83 @@ def fetch_channel_metadata(channel_id: str, max_videos: int = 30, start_video: i
return {"channel": channel_info, "videos": videos}
def fetch_channel_playlists(channel_id: str, max_results: int = 100) -> list[dict]:
"""Fetch the playlists listed on a channel's /playlists tab."""
if channel_id.startswith("@"):
url = f"https://www.youtube.com/{channel_id}/playlists"
else:
url = f"https://www.youtube.com/channel/{channel_id}/playlists"
stdout, _, code = _run([
"yt-dlp", url,
"--dump-json", "--flat-playlist",
"--playlist-end", str(max_results),
"--quiet",
*_cookie_args(),
], timeout=60)
playlists = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
pl_id = info.get("id") or info.get("playlist_id")
title = info.get("title") or info.get("playlist_title") or ""
if not pl_id or not title or pl_id == channel_id:
continue
playlists.append({
"youtube_playlist_id": pl_id,
"title": title,
"description": info.get("description"),
"thumbnail_url": _stable_thumbnail(info.get("id")) if info.get("_type") == "url" else None,
"video_count": info.get("playlist_count") or info.get("n_entries") or 0,
})
except json.JSONDecodeError:
continue
return playlists
def fetch_playlist_videos(playlist_id: str, max_videos: int = 200) -> list[dict]:
"""Fetch videos from a YouTube playlist by playlist ID."""
url = f"https://www.youtube.com/playlist?list={playlist_id}"
args = [
"yt-dlp", url,
"--dump-json", "--flat-playlist",
"--quiet",
*_cookie_args(),
]
if max_videos > 0:
args += ["--playlist-end", str(max_videos)]
stdout, _, code = _run(args, timeout=120)
videos = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
vid_id = info.get("id")
if not vid_id:
continue
videos.append({
"youtube_video_id": vid_id,
"title": info.get("title", ""),
"thumbnail_url": _stable_thumbnail(vid_id),
"duration_seconds": info.get("duration"),
"published_at": _parse_published(info),
"view_count": info.get("view_count"),
"channel": {
"youtube_channel_id": info.get("channel_id"),
"name": info.get("channel") or info.get("uploader") or "",
},
})
except json.JSONDecodeError:
continue
return videos
def fetch_featured_channels(channel_id: str) -> list[str]:
"""Fetch channel IDs from the /channels tab of a YouTube channel.