- Video model: view_count column (Integer, nullable) - ytdlp._normalize_video: extract view_count from yt-dlp info - _VIDEO_SELECT: include v.view_count in all queries - VideoDetail schema: view_count field - Watch page: formatViews() helper, show "X.XM views" in meta row alongside date and category Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
991 lines
38 KiB
Python
991 lines
38 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:
|
|
if delta > 0:
|
|
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
|
|
|
|
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,
|
|
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,
|
|
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 == "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,
|
|
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
|
|
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,
|
|
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)) * 6.0
|
|
+ COALESCE(cs.liked_count, 0) * 12.0
|
|
+ COALESCE(cs.rating_sum, 0) * 8.0) * :w_channel
|
|
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
|
|
+ COALESCE((
|
|
SELECT uta.score FROM user_tag_affinity uta
|
|
WHERE uta.user_id = :user_id
|
|
AND uta.tag = LOWER(COALESCE(v.category, ''))
|
|
LIMIT 1
|
|
), 0) * 3.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
|
|
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,
|
|
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,
|
|
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
|
|
|
|
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}/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)
|
|
|
|
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}
|