Files
youclonedl/backend/routers/videos.py
Mattias Thall aa91156bbc Add older content exploration: channel page + home feed Rediscover mode
Channel page:
- "Explore older videos" button fetches 100 videos at a time further back
  in the channel history using yt-dlp --playlist-start/--playlist-end
- "Fetch entire history" still available for full crawl
- Backend: /channels/{id}/explore?page=N endpoint + playlist offset support
  in fetch_channel_metadata(start_video=N)

Home feed:
- New "Rediscover" mode: older unwatched videos (90+ days old) from
  followed channels, randomly sampled then re-ranked by tag affinity

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

1120 lines
44 KiB
Python

import os
import random
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..auth_utils import get_current_user
from ..config import settings
from ..database import get_db
from ..models import Channel, User, UserSettings, UserTagAffinity, UserVideo, Video
from ..services import ytdlp
from ..services.scoring import get_surprise_videos, get_discovery_injection
router = APIRouter()
def _update_affinity(db: Session, user_id: int, video: Video, delta: float):
"""Adjust tag/category affinity scores for a video. delta > 0 = positive signal."""
import json as _json
tags = []
if video.category:
tags.append(video.category.lower().strip())
if video.tags:
try:
for t in _json.loads(video.tags)[:8]:
if t and t.strip():
tags.append(t.lower().strip())
except Exception:
pass
for tag in set(tags):
existing = db.query(UserTagAffinity).filter_by(user_id=user_id, tag=tag).first()
if existing:
existing.score = max(existing.score + delta, -20.0)
existing.updated_at = datetime.utcnow()
else:
db.add(UserTagAffinity(user_id=user_id, tag=tag, score=delta))
class VideoDetail(BaseModel):
id: int
youtube_video_id: str
title: str
description: Optional[str]
thumbnail_url: Optional[str]
duration_seconds: Optional[int]
published_at: Optional[datetime]
channel_id: Optional[int] = None
channel_name: Optional[str]
channel_youtube_id: Optional[str]
tags: Optional[str]
category: Optional[str]
is_downloaded: bool = False
is_watched: bool = False
liked: bool = False
watch_progress_seconds: int = 0
queued: bool = False
rating: Optional[int] = None
channel_followed: bool = False
download_resolution: Optional[str] = None
local_file_url: Optional[str] = None
is_recommended: bool = False
view_count: Optional[int] = None
like_count: Optional[int] = None
dislike_count: Optional[int] = None
channel_thumbnail_url: Optional[str] = None
model_config = {"from_attributes": True}
def _local_file_url(file_path: Optional[str]) -> Optional[str]:
if not file_path or not os.path.exists(file_path):
return None
try:
rel = os.path.relpath(file_path, settings.download_path)
return f"/files/{rel}"
except ValueError:
return None
class ProgressUpdate(BaseModel):
watch_progress_seconds: int
watched: Optional[bool] = None
def _get_uv(db: Session, user_id: int, video_id: int) -> UserVideo:
uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=video_id).first()
if not uv:
uv = UserVideo(user_id=user_id, video_id=video_id)
db.add(uv)
db.flush()
return uv
@router.get("/history", response_model=list[VideoDetail])
def watch_history(
limit: int = 25,
offset: int = 0,
channel_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
where_extra = "AND v.channel_id = :channel_id" if channel_id else ""
params: dict = {"user_id": current_user.id, "limit": limit, "offset": offset}
if channel_id:
params["channel_id"] = channel_id
rows = db.execute(
text(_VIDEO_SELECT + f"""
WHERE uv.user_id = :user_id AND uv.watched = 1
{where_extra}
ORDER BY uv.last_watched_at DESC
LIMIT :limit OFFSET :offset
"""),
params,
).mappings().all()
return [_row_to_detail(r) for r in rows]
@router.get("/home-feed", response_model=list[VideoDetail])
def home_feed(
limit: int = 25,
offset: int = 0,
mode: str = "ranked", # ranked | chronological | random | inbox
duration: str = "", # "" | short | medium | long
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
duration_clause = {
"short": "AND v.duration_seconds <= 600",
"medium": "AND v.duration_seconds > 600 AND v.duration_seconds <= 1800",
"long": "AND v.duration_seconds > 1800",
}.get(duration, "")
user_settings = db.query(UserSettings).filter_by(user_id=current_user.id).first()
hide_watched = user_settings.hide_watched_from_feed if user_settings else False
w_recency = (user_settings.feed_weight_recency if user_settings and user_settings.feed_weight_recency is not None else 5.0) / 5.0
w_affinity = (user_settings.feed_weight_affinity if user_settings and user_settings.feed_weight_affinity is not None else 5.0) / 5.0
w_channel = (user_settings.feed_weight_channel if user_settings and user_settings.feed_weight_channel is not None else 5.0) / 5.0
if mode == "chronological":
rows = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.queued, 0) AS queued,
NULL AS file_path
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_channels uc
ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed'
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE (:hide_watched = 0 OR COALESCE(uv.watched, 0) = 0)
AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now'))
{duration_clause}
ORDER BY v.published_at DESC NULLS LAST
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "limit": limit, "offset": offset, "hide_watched": 1 if hide_watched else 0},
).mappings().all()
return [
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)},
is_watched=bool(r["watched"]))
for r in rows
]
if mode == "random":
# Random videos from the discovery pool — unweighted, no score ordering
rows = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.queued, 0) AS queued,
NULL AS file_path
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN discovery_queue dq ON c.id = dq.channel_id
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE dq.user_id = :user_id AND dq.seen = 0
AND v.published_at IS NOT NULL
AND dq.channel_id NOT IN (
SELECT channel_id FROM user_channels
WHERE user_id = :user_id AND status IN ('followed', 'dismissed')
)
{duration_clause}
ORDER BY RANDOM()
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "limit": limit, "offset": offset},
).mappings().all()
return [
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)},
is_watched=bool(r["watched"]), is_recommended=True)
for r in rows
]
if mode == "rediscover":
# Older unwatched videos from followed channels, ranked by tag affinity
affinity_rows = db.execute(
text("SELECT tag, score FROM user_tag_affinity WHERE user_id = :user_id AND score > 0"),
{"user_id": current_user.id},
).mappings().all()
affinity = {r["tag"]: r["score"] for r in affinity_rows}
rows = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.queued, 0) AS queued,
NULL AS file_path
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_channels uc
ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed'
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE COALESCE(uv.watched, 0) = 0
AND v.published_at < datetime('now', '-90 days')
AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now'))
{duration_clause}
ORDER BY RANDOM()
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "limit": min(limit * 4, 200), "offset": offset},
).mappings().all()
if affinity:
import json as _json
def _affinity_score(row):
try:
tags = _json.loads(row["tags"] or "[]")
return sum(affinity.get(t.lower().strip(), 0) for t in tags if isinstance(t, str))
except Exception:
return 0
rows = sorted(rows, key=_affinity_score, reverse=True)
rows = rows[:limit]
return [
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)},
is_watched=bool(r["watched"]))
for r in rows
]
if mode == "inbox":
rows = db.execute(
text(f"""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.queued, 0) AS queued,
NULL AS file_path
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_channels uc
ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed'
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE COALESCE(uv.watched, 0) = 0
AND (
(uc.last_seen_at IS NULL AND v.indexed_at >= datetime('now', '-7 days'))
OR
(uc.last_seen_at IS NOT NULL AND v.indexed_at > uc.last_seen_at)
)
{duration_clause}
ORDER BY v.indexed_at DESC
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "limit": limit, "offset": offset},
).mappings().all()
return [
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched",)},
is_watched=bool(r["watched"]))
for r in rows
]
# mode == "ranked" (default)
rows = db.execute(
text(f"""
WITH channel_stats AS (
SELECT
v.channel_id,
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count,
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum,
AVG(CASE WHEN uv.completion_percent IS NOT NULL THEN uv.completion_percent END) AS avg_completion_pct
FROM videos v
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
GROUP BY v.channel_id
),
scored AS (
SELECT
v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.queued, 0) AS queued,
uv.rating AS rating,
NULL AS file_path,
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
+ COALESCE(cs.liked_count, 0) * 10.0
+ COALESCE(cs.rating_sum, 0) * 8.0
+ COALESCE(cs.avg_completion_pct, 50.0) * 0.08) * :w_channel
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
+ COALESCE((
SELECT COALESCE(SUM(uta.score), 0)
FROM user_tag_affinity uta
WHERE uta.user_id = :user_id
AND (uta.tag = LOWER(COALESCE(v.category, ''))
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
LIMIT 5
), 0) * :w_affinity
AS score,
ROW_NUMBER() OVER (
PARTITION BY v.channel_id
ORDER BY v.published_at DESC NULLS LAST, v.id DESC
) AS rn
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN user_channels uc
ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed'
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
LEFT JOIN channel_stats cs ON v.channel_id = cs.channel_id
WHERE (:hide_watched = 0 OR COALESCE(uv.watched, 0) = 0)
AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now'))
{duration_clause}
)
SELECT * FROM scored
WHERE rn <= 3
ORDER BY score DESC, RANDOM()
LIMIT :limit OFFSET :offset
"""),
{"user_id": current_user.id, "limit": limit, "offset": offset, "hide_watched": 1 if hide_watched else 0,
"w_recency": w_recency, "w_affinity": w_affinity, "w_channel": w_channel},
).mappings().all()
followed = [
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched", "score", "rn")},
is_watched=bool(r["watched"]))
for r in rows
]
# Inject discovery cards on every page: 1 every 5 followed cards.
disc_per_page = max(limit // 5, 1)
disc_offset = (offset // limit) * disc_per_page if limit > 0 else 0
disc_rows = db.execute(
text("""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url
FROM discovery_queue dq
JOIN channels c ON dq.channel_id = c.id
JOIN videos v ON v.channel_id = c.id
WHERE dq.user_id = :user_id AND dq.seen = 0
AND v.published_at IS NOT NULL
AND dq.channel_id NOT IN (
SELECT channel_id FROM user_channels
WHERE user_id = :user_id AND status IN ('followed', 'dismissed')
)
AND v.id = (
SELECT id FROM videos
WHERE channel_id = c.id AND published_at IS NOT NULL
ORDER BY published_at DESC LIMIT 1
)
ORDER BY dq.score DESC
LIMIT :disc_limit OFFSET :disc_offset
"""),
{"user_id": current_user.id, "disc_limit": disc_per_page, "disc_offset": disc_offset},
).mappings().all()
disc = [
VideoDetail(**{k: v for k, v in dict(r).items()},
is_recommended=True, is_watched=False, is_downloaded=False)
for r in disc_rows
]
# Interleave: one discovery card every 5 followed cards
result: list[VideoDetail] = []
disc_iter = iter(disc)
for i, v in enumerate(followed):
if i > 0 and i % 5 == 0:
rec = next(disc_iter, None)
if rec:
result.append(rec)
result.append(v)
result.extend(disc_iter)
return result
@router.get("/continue-watching", response_model=list[VideoDetail])
def continue_watching(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rows = db.execute(
text("""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
uv.watched, uv.watch_progress_seconds, uv.downloaded AS is_downloaded,
uv.queued
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
LEFT JOIN channels c ON v.channel_id = c.id
WHERE uv.user_id = :user_id
AND uv.watch_progress_seconds > 0
AND (uv.watched IS NULL OR uv.watched = 0)
ORDER BY uv.last_watched_at DESC
LIMIT 20
"""),
{"user_id": current_user.id},
).mappings().all()
return [VideoDetail(**{k: v for k, v in dict(r).items() if k != "watched"},
is_watched=bool(r["watched"])) for r in rows]
@router.get("/long", response_model=list[VideoDetail])
def long_videos(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rows = db.execute(
text("""
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.queued, 0) AS queued
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 uv.downloaded = 1
AND v.duration_seconds > 2700
ORDER BY RANDOM()
LIMIT 20
"""),
{"user_id": current_user.id},
).mappings().all()
return [VideoDetail(**dict(r), is_watched=bool(r["watched"])) for r in rows]
@router.get("/surprise", response_model=list[dict])
def surprise_me(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
results = get_surprise_videos(db, current_user.id, limit=10)
# 1 in 5 chance: inject a discovery item
if random.random() < 0.2:
injection = get_discovery_injection(db, current_user.id)
if injection and results:
results.insert(random.randint(0, min(4, len(results))), {**injection, "is_discovery": True})
return results
_VIDEO_SELECT = """
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category, v.view_count, v.like_count, v.dislike_count,
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.liked, 0) AS liked,
COALESCE(uv.queued, 0) AS queued,
uv.rating AS rating,
CASE WHEN uc.id IS NOT NULL THEN 1 ELSE 0 END AS channel_followed,
d.file_path, d.resolution AS download_resolution
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
LEFT JOIN downloads d ON v.id = d.video_id AND d.user_id = :user_id AND d.status = 'complete'
LEFT JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :user_id AND uc.status = 'followed'
"""
def _row_to_detail(row) -> VideoDetail:
r = dict(row)
return VideoDetail(
**{k: v for k, v in r.items() if k not in ("watched", "file_path", "score")},
is_watched=bool(r["watched"]),
local_file_url=_local_file_url(r.get("file_path")),
)
def _upsert_video_from_yt(db: Session, youtube_video_id: str) -> bool:
"""Fetch fresh metadata from yt-dlp and upsert video + channel. Returns True if successful."""
meta = ytdlp.fetch_video_metadata(youtube_video_id)
if not meta:
return False
ch_data = meta.pop("channel", {}) or {}
yt_channel_id = ch_data.get("youtube_channel_id")
channel = None
if yt_channel_id:
channel = db.query(Channel).filter_by(youtube_channel_id=yt_channel_id).first()
if not channel:
channel = Channel(**{k: v for k, v in ch_data.items() if hasattr(Channel, k)})
db.add(channel)
db.flush()
else:
for k, v in ch_data.items():
if hasattr(channel, k) and v is not None and k != "thumbnail_url":
setattr(channel, k, v)
video = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first()
if not video:
video = Video(
channel_id=channel.id if channel else None,
**{k: v for k, v in meta.items() if hasattr(Video, k)},
)
db.add(video)
else:
for k, v in meta.items():
if not hasattr(video, k) or v is None:
continue
# Don't overwrite already-set description/tags with empty strings from yt-dlp
if k in ("description", "tags") and v == "" and getattr(video, k) is not None:
continue
setattr(video, k, v)
if channel:
video.channel_id = channel.id
# Fetch crowdsourced dislike count if not already known
if not video.dislike_count:
dislikes = ytdlp.fetch_dislike_count(youtube_video_id)
if dislikes is not None:
video.dislike_count = dislikes
db.commit()
return True
class BookmarkOut(BaseModel):
id: int
video_id: int
timestamp_seconds: int
note: Optional[str]
source: str = "manual"
created_at: datetime
model_config = {"from_attributes": True}
class BookmarkCreate(BaseModel):
timestamp_seconds: int
note: Optional[str] = ""
class BookmarkPatch(BaseModel):
note: str
@router.get("/{video_id}/bookmarks", response_model=list[BookmarkOut])
def get_bookmarks(
video_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import VideoBookmark
items = db.query(VideoBookmark).filter_by(user_id=current_user.id, video_id=video_id).order_by(VideoBookmark.timestamp_seconds).all()
return [BookmarkOut.model_validate(b) for b in items]
@router.post("/{video_id}/bookmarks", response_model=BookmarkOut, status_code=201)
def create_bookmark(
video_id: int,
body: BookmarkCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import VideoBookmark
video = db.query(Video).filter(Video.id == video_id).first()
if not video:
raise HTTPException(status_code=404, detail="Video not found")
bm = VideoBookmark(
user_id=current_user.id,
video_id=video_id,
timestamp_seconds=body.timestamp_seconds,
note=body.note or "",
)
db.add(bm)
_update_affinity(db, current_user.id, video, +2.0)
db.commit()
db.refresh(bm)
return BookmarkOut.model_validate(bm)
@router.post("/{video_id}/bookmarks/import-chapters", response_model=list[BookmarkOut], status_code=200)
def import_chapters(
video_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create auto bookmarks from stored chapter data. Idempotent — skips if already imported.
If chapters have never been fetched (NULL), refreshes metadata from yt-dlp first."""
from ..models import VideoBookmark
import json as _json
video = db.query(Video).filter(Video.id == video_id).first()
if not video:
return []
# chapters=NULL means never fetched; fetch now and cache the result (even if empty)
if video.chapters is None:
_upsert_video_from_yt(db, video.youtube_video_id)
db.refresh(video)
# Mark as checked even if no chapters found, so we don't re-fetch next time
if video.chapters is None:
video.chapters = "[]"
db.commit()
chapters = _json.loads(video.chapters or "[]")
# Skip if trivial (single chapter) or already imported
if len(chapters) < 2:
return []
existing = db.query(VideoBookmark).filter_by(user_id=current_user.id, video_id=video_id, source="auto").first()
if existing:
return []
created = []
for ch in chapters:
bm = VideoBookmark(
user_id=current_user.id,
video_id=video_id,
timestamp_seconds=ch["start_time"],
note=ch["title"],
source="auto",
)
db.add(bm)
created.append(bm)
db.commit()
for bm in created:
db.refresh(bm)
return [BookmarkOut.model_validate(bm) for bm in created]
@router.delete("/{video_id}/bookmarks/clear-chapters", status_code=204)
def clear_chapters(
video_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete all auto-imported chapter bookmarks for this video."""
from ..models import VideoBookmark
db.query(VideoBookmark).filter_by(user_id=current_user.id, video_id=video_id, source="auto").delete()
db.commit()
@router.patch("/{video_id}/bookmarks/{bookmark_id}", response_model=BookmarkOut)
def update_bookmark(
video_id: int,
bookmark_id: int,
body: BookmarkPatch,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import VideoBookmark
bm = db.query(VideoBookmark).filter_by(id=bookmark_id, user_id=current_user.id, video_id=video_id).first()
if not bm:
raise HTTPException(status_code=404, detail="Bookmark not found")
bm.note = body.note
db.commit()
db.refresh(bm)
return BookmarkOut.model_validate(bm)
@router.delete("/{video_id}/bookmarks/{bookmark_id}", status_code=204)
def delete_bookmark(
video_id: int,
bookmark_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import VideoBookmark
bm = db.query(VideoBookmark).filter_by(id=bookmark_id, user_id=current_user.id, video_id=video_id).first()
if bm:
db.delete(bm)
db.commit()
@router.get("/by-yt/{youtube_video_id}/subs")
def get_available_subs(
youtube_video_id: str,
current_user: User = Depends(get_current_user),
):
"""Return subtitle languages available on YouTube for a video (yt-dlp call, slow)."""
return ytdlp.fetch_available_subs(youtube_video_id)
@router.post("/by-yt/{youtube_video_id}/download-subs")
def download_subs(
youtube_video_id: str,
body: dict,
current_user: User = Depends(get_current_user),
):
"""Download subtitle file(s) only for an already-downloaded video."""
langs = (body.get("subtitle_langs") or "").strip()
if not langs:
raise HTTPException(status_code=400, detail="subtitle_langs required")
ok = ytdlp.download_subs_only(youtube_video_id, langs)
if not ok:
raise HTTPException(status_code=500, detail="Subtitle download failed")
return {"ok": True}
@router.get("/by-yt/{youtube_video_id}/subtitle-files")
def list_subtitle_files(
youtube_video_id: str,
current_user: User = Depends(get_current_user),
):
"""List .vtt subtitle files already on disk for a downloaded video (instant)."""
import re as _re
from pathlib import Path
from ..config import settings as _cfg
pat = _re.compile(rf'^{_re.escape(youtube_video_id)}\.(.+)\.vtt$')
subs = []
try:
for f in Path(_cfg.download_path).iterdir():
m = pat.match(f.name)
if m:
subs.append({"lang": m.group(1), "url": f"/files/{f.name}"})
except Exception:
pass
return sorted(subs, key=lambda s: s["lang"])
@router.get("/by-yt/{youtube_video_id}/comments")
def get_comments(
youtube_video_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import VideoComment
video = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first()
if not video:
return []
comments = (
db.query(VideoComment)
.filter_by(video_id=video.id)
.order_by(VideoComment.is_pinned.desc(), VideoComment.likes.desc())
.all()
)
return [
{
"author": c.author,
"text": c.text,
"likes": c.likes,
"is_pinned": c.is_pinned,
"published_at": c.published_at,
}
for c in comments
]
@router.post("/by-yt/{youtube_video_id}/comments/refresh")
def refresh_comments(
youtube_video_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import VideoComment
video = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first()
if not video:
raise HTTPException(status_code=404, detail="Video not found")
# Clear existing and re-fetch
db.query(VideoComment).filter_by(video_id=video.id).delete()
db.commit()
fetched = ytdlp.fetch_video_comments(youtube_video_id)
for c in fetched:
db.add(VideoComment(video_id=video.id, **c))
db.commit()
comments = (
db.query(VideoComment)
.filter_by(video_id=video.id)
.order_by(VideoComment.is_pinned.desc(), VideoComment.likes.desc())
.all()
)
return [
{
"author": c.author,
"text": c.text,
"likes": c.likes,
"is_pinned": c.is_pinned,
"published_at": c.published_at,
}
for c in comments
]
@router.get("/queue", response_model=list[VideoDetail])
def queued_videos(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rows = db.execute(
text(_VIDEO_SELECT + """
WHERE uv.user_id = :user_id AND uv.queued = 1
ORDER BY uv.id DESC
"""),
{"user_id": current_user.id},
).mappings().all()
return [_row_to_detail(r) for r in rows]
@router.get("/liked", response_model=list[VideoDetail])
def liked_videos(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rows = db.execute(
text(_VIDEO_SELECT + """
WHERE uv.user_id = :user_id AND uv.liked = 1
ORDER BY uv.liked_at DESC
"""),
{"user_id": current_user.id},
).mappings().all()
return [_row_to_detail(r) for r in rows]
@router.get("/by-yt/{youtube_video_id}", response_model=VideoDetail)
def get_video_by_yt_id(
youtube_video_id: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
existing = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first()
if not existing or not existing.title:
# Video unknown — must block to get at least a title before we can render anything
_upsert_video_from_yt(db, youtube_video_id)
elif existing.description is None or existing.chapters is None:
# Video known but missing enrichment — fetch in background, return immediately
from ..database import SessionLocal
def _enrich(yt_id: str):
bg_db = SessionLocal()
try:
_upsert_video_from_yt(bg_db, yt_id)
finally:
bg_db.close()
background_tasks.add_task(_enrich, youtube_video_id)
row = db.execute(
text(_VIDEO_SELECT + "WHERE v.youtube_video_id = :yt_id"),
{"user_id": current_user.id, "yt_id": youtube_video_id},
).mappings().first()
if not row:
raise HTTPException(status_code=404, detail="Video not found")
return _row_to_detail(row)
@router.get("/{video_id}/related", response_model=list[VideoDetail])
def related_videos(
video_id: int,
mode: str = "weighted", # weighted | random
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Videos from discovery-queue channels, ordered by discovery score or randomly."""
video = db.query(Video).filter(Video.id == video_id).first()
if not video:
raise HTTPException(status_code=404, detail="Video not found")
if mode == "random":
order_clause = "ORDER BY RANDOM()"
else:
order_clause = "ORDER BY rn ASC, score DESC, RANDOM()"
rows = db.execute(
text(f"""
SELECT * FROM (
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
COALESCE(uv.liked, 0) AS liked,
COALESCE(uv.queued, 0) AS queued,
0 AS channel_followed,
NULL AS file_path, NULL AS download_resolution,
dq.score,
ROW_NUMBER() OVER (
PARTITION BY v.channel_id
ORDER BY v.published_at DESC NULLS LAST
) AS rn
FROM videos v
JOIN channels c ON v.channel_id = c.id
JOIN discovery_queue dq ON c.id = dq.channel_id
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
WHERE dq.user_id = :user_id AND dq.seen = 0
AND dq.channel_id NOT IN (
SELECT channel_id FROM user_channels
WHERE user_id = :user_id AND status IN ('followed', 'dismissed')
)
AND v.channel_id != :channel_id
)
WHERE rn <= 2
{order_clause}
LIMIT 14
"""),
{"user_id": current_user.id, "channel_id": video.channel_id or 0},
).mappings().all()
return [_row_to_detail(r) for r in rows]
@router.get("/{video_id}", response_model=VideoDetail)
def get_video(
video_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
row = db.execute(
text(_VIDEO_SELECT + "WHERE v.id = :video_id"),
{"user_id": current_user.id, "video_id": video_id},
).mappings().first()
if not row:
raise HTTPException(status_code=404, detail="Video not found")
return _row_to_detail(row)
@router.patch("/{video_id}/progress")
def update_progress(
video_id: int,
body: ProgressUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
video = db.query(Video).filter(Video.id == video_id).first()
if not video:
raise HTTPException(status_code=404, detail="Video not found")
uv = _get_uv(db, current_user.id, video_id)
from ..models import Download
from datetime import timedelta
prev_watched = bool(uv.watched)
uv.watch_progress_seconds = body.watch_progress_seconds
uv.last_watched_at = datetime.utcnow()
# Compute completion percent whenever we have duration
if video.duration_seconds and video.duration_seconds > 0:
uv.completion_percent = round(
min(body.watch_progress_seconds / video.duration_seconds * 100, 100), 1
)
if body.watched is not None:
if body.watched and not prev_watched:
# First completion — positive affinity signal
uv.watched = True
_update_affinity(db, current_user.id, video, +2.0)
dl = db.query(Download).filter_by(
user_id=current_user.id, video_id=video_id, status="complete"
).filter(Download.pending_delete_at.is_(None)).first()
if dl:
dl.pending_delete_at = datetime.utcnow() + timedelta(days=7)
elif body.watched and prev_watched:
# Rewatch — strongest positive signal
uv.rewatch_count = (uv.rewatch_count or 0) + 1
_update_affinity(db, current_user.id, video, +3.0)
elif not body.watched:
uv.watched = False
# Early bail signal: navigating away before 20% without marking watched
elif not prev_watched and video.duration_seconds and video.duration_seconds > 60:
pct = body.watch_progress_seconds / video.duration_seconds
if pct < 0.20:
_update_affinity(db, current_user.id, video, -0.5)
# Backend safety net: auto-mark watched at ≥90% completion even if the frontend
# didn't send watched=True (e.g. browser closed before debounce fired)
if (not prev_watched and not uv.watched
and uv.completion_percent is not None and uv.completion_percent >= 90
and video.duration_seconds and video.duration_seconds > 60):
uv.watched = True
_update_affinity(db, current_user.id, video, +2.0)
dl = db.query(Download).filter_by(
user_id=current_user.id, video_id=video_id, status="complete"
).filter(Download.pending_delete_at.is_(None)).first()
if dl:
dl.pending_delete_at = datetime.utcnow() + timedelta(days=7)
db.commit()
return {"ok": True}
@router.post("/{video_id}/like")
def toggle_like(
video_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import DiscoveryQueue, UserChannel
video = db.query(Video).filter(Video.id == video_id).first()
if not video:
raise HTTPException(status_code=404, detail="Video not found")
uv = _get_uv(db, current_user.id, video_id)
uv.liked = not uv.liked
uv.liked_at = datetime.utcnow() if uv.liked else None
# When liking a video from a channel not yet followed, boost that channel's
# discovery score directly so it rises to the top of recommendations.
if uv.liked and video.channel_id:
uc = db.query(UserChannel).filter_by(
user_id=current_user.id, channel_id=video.channel_id,
).first()
not_followed = not uc or uc.status not in ("followed", "dismissed")
if not_followed:
dq = db.query(DiscoveryQueue).filter_by(
user_id=current_user.id, channel_id=video.channel_id,
).first()
if dq:
dq.score += 30.0
else:
db.add(DiscoveryQueue(
user_id=current_user.id,
channel_id=video.channel_id,
score=30.0,
source="liked",
))
# Affinity: like = strong positive, unlike = remove that boost
_update_affinity(db, current_user.id, video, +3.0 if uv.liked else -3.0)
db.commit()
return {"liked": uv.liked}
class RateBody(BaseModel):
rating: int # 1 = thumbs up, -1 = thumbs down, 0 = clear
@router.post("/{video_id}/rate")
def rate_video(
video_id: int,
body: RateBody,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import DiscoveryQueue, UserChannel
video = db.query(Video).filter(Video.id == video_id).first()
if not video:
raise HTTPException(status_code=404, detail="Video not found")
uv = _get_uv(db, current_user.id, video_id)
old_rating = uv.rating or 0
new_rating = body.rating if body.rating in (1, -1) else None
uv.rating = new_rating
# Adjust discovery score for unfolowed channels
if video.channel_id:
uc = db.query(UserChannel).filter_by(
user_id=current_user.id, channel_id=video.channel_id,
).first()
if not uc or uc.status not in ("followed", "dismissed"):
delta = (body.rating if body.rating in (1, -1) else 0) * 15 - old_rating * 15
if delta != 0:
dq = db.query(DiscoveryQueue).filter_by(
user_id=current_user.id, channel_id=video.channel_id,
).first()
if dq:
dq.score = max(dq.score + delta, -50)
if dq.score < 0:
dq.seen = True
elif delta > 0:
db.add(DiscoveryQueue(
user_id=current_user.id,
channel_id=video.channel_id,
score=float(delta),
source="rated",
))
db.commit()
return {"rating": uv.rating}
@router.post("/{video_id}/queue")
def toggle_queue(
video_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
video = db.query(Video).filter(Video.id == video_id).first()
if not video:
raise HTTPException(status_code=404, detail="Video not found")
uv = _get_uv(db, current_user.id, video_id)
uv.queued = not uv.queued
db.commit()
return {"queued": uv.queued}