Files
youclonedl/backend/routers/playlists.py
Mattias Thall c3b83ba1d3 Enrich playlist video dates after indexing
flat-playlist mode returns timestamp=null for most playlist entries so
published_at is missing after the initial index. Now kicks off
_enrich_missing_task (scoped to the playlist size) as a daemon thread
immediately after indexing commits, filling in dates and view counts
in the background via individual video fetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:50:56 +02:00

221 lines
7.9 KiB
Python

import json
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..auth_utils import get_current_user
from ..database import get_db
from ..models import Channel, Playlist, Video, User
from ..services import ytdlp
router = APIRouter()
class PlaylistOut(BaseModel):
id: int
youtube_playlist_id: str
channel_id: Optional[int]
title: str
description: Optional[str]
thumbnail_url: Optional[str]
video_count: int
indexed_at: Optional[datetime]
model_config = {"from_attributes": True}
class PlaylistVideoOut(BaseModel):
id: int
youtube_video_id: str
title: str
thumbnail_url: Optional[str]
duration_seconds: Optional[int]
published_at: Optional[datetime]
view_count: Optional[int]
is_downloaded: bool = False
is_watched: bool = False
channel_id: Optional[int] = None
channel_name: Optional[str] = None
model_config = {"from_attributes": True}
@router.get("/channel/{channel_id}", response_model=list[PlaylistOut])
def get_channel_playlists(
channel_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rows = db.query(Playlist).filter_by(channel_id=channel_id).order_by(Playlist.video_count.desc()).all()
return rows
@router.post("/channel/{channel_id}/fetch", status_code=status.HTTP_202_ACCEPTED)
def fetch_channel_playlists(
channel_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
channel = db.query(Channel).filter_by(id=channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
background_tasks.add_task(_fetch_playlists_task, channel_id, channel.youtube_channel_id)
return {"detail": "Fetching playlists"}
def _first_video_thumbnail(youtube_playlist_id: str) -> Optional[str]:
"""Fetch just the first video of a playlist and return a stable thumbnail URL."""
try:
videos = ytdlp.fetch_playlist_videos(youtube_playlist_id, max_videos=1)
if videos:
vid_id = videos[0].get("youtube_video_id")
if vid_id:
from ..services.ytdlp import _stable_thumbnail
return _stable_thumbnail(vid_id)
except Exception:
pass
return None
def _fetch_playlists_task(channel_id: int, youtube_channel_id: str):
from ..database import SessionLocal
db = SessionLocal()
try:
playlists = ytdlp.fetch_channel_playlists(youtube_channel_id)
for pl in playlists:
pl_id = pl["youtube_playlist_id"]
thumb = pl.get("thumbnail_url") or _first_video_thumbnail(pl_id)
existing = db.query(Playlist).filter_by(youtube_playlist_id=pl_id).first()
if existing:
existing.title = pl["title"] or existing.title
if pl.get("video_count"):
existing.video_count = pl["video_count"]
if thumb and not existing.thumbnail_url:
existing.thumbnail_url = thumb
else:
db.add(Playlist(
youtube_playlist_id=pl_id,
channel_id=channel_id,
title=pl["title"],
description=pl.get("description"),
thumbnail_url=thumb,
video_count=pl.get("video_count") or 0,
))
db.commit()
except Exception:
db.rollback()
finally:
db.close()
@router.get("/{playlist_id}/videos", response_model=list[PlaylistVideoOut])
def get_playlist_videos(
playlist_id: int,
offset: int = 0,
limit: int = 60,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
if not playlist.indexed_at:
return []
rows = db.execute(
text("""
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
v.duration_seconds, v.published_at, v.view_count,
c.id AS channel_id, c.name AS channel_name,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.watched, 0) AS is_watched
FROM videos v
LEFT JOIN channels c ON v.channel_id = c.id
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE v.youtube_video_id IN (
SELECT value FROM json_each((
SELECT video_ids FROM playlists WHERE id = :playlist_id
))
)
ORDER BY v.published_at DESC NULLS LAST
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "playlist_id": playlist_id, "limit": limit, "offset": offset},
).mappings().all()
return [PlaylistVideoOut(**dict(r)) for r in rows]
@router.post("/{playlist_id}/index", status_code=status.HTTP_202_ACCEPTED)
def index_playlist(
playlist_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
background_tasks.add_task(_index_playlist_task, playlist_id, playlist.youtube_playlist_id, playlist.channel_id)
return {"detail": "Indexing playlist"}
def _index_playlist_task(playlist_id: int, youtube_playlist_id: str, channel_id: Optional[int]):
from ..database import SessionLocal
db = SessionLocal()
try:
videos = ytdlp.fetch_playlist_videos(youtube_playlist_id)
playlist = db.query(Playlist).filter_by(id=playlist_id).first()
if not playlist:
return
video_yt_ids = []
for vdata in videos:
yt_id = vdata.get("youtube_video_id")
if not yt_id:
continue
video_yt_ids.append(yt_id)
existing = db.query(Video).filter_by(youtube_video_id=yt_id).first()
if existing:
if vdata.get("view_count") is not None:
existing.view_count = vdata["view_count"]
else:
ch_id = channel_id
if not ch_id and vdata.get("channel", {}).get("youtube_channel_id"):
ch = db.query(Channel).filter_by(
youtube_channel_id=vdata["channel"]["youtube_channel_id"]
).first()
if ch:
ch_id = ch.id
db.add(Video(
youtube_video_id=yt_id,
channel_id=ch_id,
title=vdata.get("title", ""),
thumbnail_url=vdata.get("thumbnail_url"),
duration_seconds=vdata.get("duration_seconds"),
published_at=vdata.get("published_at"),
view_count=vdata.get("view_count"),
tags="[]",
))
playlist.video_count = len(video_yt_ids)
playlist.indexed_at = datetime.utcnow()
playlist.video_ids = json.dumps(video_yt_ids)
if not playlist.thumbnail_url and video_yt_ids:
from ..services.ytdlp import _stable_thumbnail
playlist.thumbnail_url = _stable_thumbnail(video_yt_ids[0])
db.commit()
except Exception:
db.rollback()
return
finally:
db.close()
# Enrich dates and view counts for videos missing them — runs in background
import threading
from ..routers.channels import _enrich_missing_task
threading.Thread(target=_enrich_missing_task, args=(len(video_yt_ids),), daemon=True).start()