Initial commit — YT Hub
Self-hosted personal YouTube management app. FastAPI + SQLite backend, React + Vite + Tailwind frontend. Dockerfiles and compose included for Portainer deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
923
backend/routers/videos.py
Normal file
923
backend/routers/videos.py
Normal file
@@ -0,0 +1,923 @@
|
||||
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
|
||||
|
||||
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,
|
||||
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("/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}
|
||||
Reference in New Issue
Block a user