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:
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
86
backend/routers/admin.py
Normal file
86
backend/routers/admin.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import SystemConfig, User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
return current_user
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
is_admin: bool
|
||||
created_at: Optional[datetime]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ConfigOut(BaseModel):
|
||||
allow_registration: bool
|
||||
|
||||
|
||||
class ConfigPatch(BaseModel):
|
||||
allow_registration: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(_require_admin),
|
||||
):
|
||||
return db.query(User).order_by(User.id).all()
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=204)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(_require_admin),
|
||||
):
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
user = db.query(User).filter_by(id=user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/config", response_model=ConfigOut)
|
||||
def get_config(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(_require_admin),
|
||||
):
|
||||
row = db.query(SystemConfig).filter_by(key="allow_registration").first()
|
||||
return ConfigOut(allow_registration=row.value == "true" if row else True)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=ConfigOut)
|
||||
def update_config(
|
||||
body: ConfigPatch,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(_require_admin),
|
||||
):
|
||||
if body.allow_registration is not None:
|
||||
row = db.query(SystemConfig).filter_by(key="allow_registration").first()
|
||||
if row:
|
||||
row.value = "true" if body.allow_registration else "false"
|
||||
else:
|
||||
db.add(SystemConfig(key="allow_registration",
|
||||
value="true" if body.allow_registration else "false"))
|
||||
db.commit()
|
||||
row = db.query(SystemConfig).filter_by(key="allow_registration").first()
|
||||
return ConfigOut(allow_registration=row.value == "true" if row else True)
|
||||
76
backend/routers/auth.py
Normal file
76
backend/routers/auth.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth_utils import create_access_token, get_current_user, hash_password, verify_password
|
||||
from ..database import get_db
|
||||
from ..models import SystemConfig, User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
is_admin: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register(body: RegisterRequest, db: Session = Depends(get_db)):
|
||||
# Allow registration if no users exist yet (bootstrap), otherwise check config
|
||||
has_users = db.query(User).first() is not None
|
||||
if has_users:
|
||||
cfg = db.query(SystemConfig).filter_by(key="allow_registration").first()
|
||||
if cfg and cfg.value != "true":
|
||||
raise HTTPException(status_code=403, detail="Registration is disabled")
|
||||
|
||||
if db.query(User).filter(User.username == body.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
if db.query(User).filter(User.email == body.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
is_first = not has_users
|
||||
user = User(
|
||||
username=body.username,
|
||||
email=body.email,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_admin=is_first,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == form.username).first()
|
||||
if not user or not verify_password(form.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
712
backend/routers/channels.py
Normal file
712
backend/routers/channels.py
Normal file
@@ -0,0 +1,712 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import Channel, ChannelGroup, ChannelGroupMember, Download, User, UserChannel, UserSettings, UserVideo, Video
|
||||
from ..services import ytdlp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ChannelOut(BaseModel):
|
||||
id: int
|
||||
youtube_channel_id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
thumbnail_url: Optional[str]
|
||||
banner_url: Optional[str]
|
||||
crawled_at: Optional[datetime]
|
||||
status: Optional[str]
|
||||
auto_download: Optional[bool] = None
|
||||
subscriber_count: Optional[int] = None
|
||||
video_count: int = 0
|
||||
unwatched_count: int = 0
|
||||
watched_count: int = 0
|
||||
downloaded_count: int = 0
|
||||
last_published_at: Optional[datetime] = None
|
||||
new_count: int = 0
|
||||
latest_video_id: Optional[str] = None
|
||||
latest_video_title: Optional[str] = None
|
||||
muted_until: Optional[datetime] = None
|
||||
upload_frequency_days: Optional[float] = None
|
||||
notes: Optional[str] = ""
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ChannelGroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
channel_ids: list[int] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class VideoOut(BaseModel):
|
||||
id: int
|
||||
youtube_video_id: str
|
||||
title: str
|
||||
thumbnail_url: Optional[str]
|
||||
duration_seconds: Optional[int]
|
||||
published_at: Optional[datetime]
|
||||
channel_id: Optional[int] = None
|
||||
channel_name: Optional[str] = None
|
||||
channel_youtube_id: Optional[str] = None
|
||||
is_downloaded: bool = False
|
||||
is_watched: bool = False
|
||||
queued: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
_CHANNEL_STATS_SELECT = """
|
||||
SELECT c.*, uc.status, uc.auto_download, uc.muted_until, uc.notes,
|
||||
(SELECT COUNT(*) FROM videos WHERE channel_id = c.id) AS video_count,
|
||||
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_published_at,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND COALESCE(uv.watched, 0) = 0) AS unwatched_count,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND uv.watched = 1) AS watched_count,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND uv.downloaded = 1) AS downloaded_count,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
WHERE v.channel_id = c.id
|
||||
AND (uc.last_seen_at IS NULL OR v.indexed_at > uc.last_seen_at)) AS new_count,
|
||||
(SELECT v.youtube_video_id FROM videos v
|
||||
WHERE v.channel_id = c.id ORDER BY v.published_at DESC LIMIT 1) AS latest_video_id,
|
||||
(SELECT v.title FROM videos v
|
||||
WHERE v.channel_id = c.id ORDER BY v.published_at DESC LIMIT 1) AS latest_video_title,
|
||||
(SELECT
|
||||
CASE WHEN COUNT(*) < 2 THEN NULL
|
||||
ELSE CAST((julianday(MAX(sub.published_at)) - julianday(MIN(sub.published_at))) AS REAL) / (COUNT(*) - 1)
|
||||
END
|
||||
FROM (SELECT published_at FROM videos WHERE channel_id = c.id AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 15) sub
|
||||
) AS upload_frequency_days
|
||||
FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :user_id AND uc.status = 'followed'
|
||||
"""
|
||||
|
||||
|
||||
def _get_channel_or_404(db: Session, channel_id: int) -> Channel:
|
||||
c = db.query(Channel).filter(Channel.id == channel_id).first()
|
||||
if not c:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return c
|
||||
|
||||
|
||||
def _index_channel_task(channel_id: int, user_id: int):
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
channel = db.query(Channel).filter_by(id=channel_id).first()
|
||||
if not channel:
|
||||
return
|
||||
|
||||
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id)
|
||||
if not result:
|
||||
return
|
||||
|
||||
ch_data = result.get("channel", {})
|
||||
if ch_data:
|
||||
for k, v in ch_data.items():
|
||||
if hasattr(channel, k) and v is not None and v != "":
|
||||
setattr(channel, k, v)
|
||||
channel.crawled_at = datetime.utcnow()
|
||||
db.merge(channel)
|
||||
|
||||
new_video_ids = []
|
||||
for vdata in result.get("videos", []):
|
||||
yt_id = vdata.get("youtube_video_id")
|
||||
if not yt_id:
|
||||
continue
|
||||
existing = db.query(Video).filter_by(youtube_video_id=yt_id).first()
|
||||
if not existing:
|
||||
new_video = Video(
|
||||
youtube_video_id=yt_id,
|
||||
channel_id=channel.id,
|
||||
title=vdata.get("title", ""),
|
||||
description=vdata.get("description"),
|
||||
thumbnail_url=vdata.get("thumbnail_url"),
|
||||
duration_seconds=vdata.get("duration_seconds"),
|
||||
published_at=vdata.get("published_at"),
|
||||
tags=vdata.get("tags"),
|
||||
category=vdata.get("category"),
|
||||
)
|
||||
db.add(new_video)
|
||||
db.flush()
|
||||
new_video_ids.append((yt_id, new_video.id))
|
||||
else:
|
||||
# Backfill missing metadata on existing videos
|
||||
if existing.published_at is None and vdata.get("published_at"):
|
||||
existing.published_at = vdata["published_at"]
|
||||
if not existing.title and vdata.get("title"):
|
||||
existing.title = vdata["title"]
|
||||
if not existing.thumbnail_url and vdata.get("thumbnail_url"):
|
||||
existing.thumbnail_url = vdata["thumbnail_url"]
|
||||
if not existing.duration_seconds and vdata.get("duration_seconds"):
|
||||
existing.duration_seconds = vdata["duration_seconds"]
|
||||
if not existing.description and vdata.get("description"):
|
||||
existing.description = vdata["description"]
|
||||
db.commit()
|
||||
|
||||
# Auto-download new videos if setting says to
|
||||
if new_video_ids and user_id:
|
||||
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
|
||||
user_settings = db.query(UserSettings).filter_by(user_id=user_id).first()
|
||||
global_auto = user_settings.auto_download_on_sync if user_settings else False
|
||||
channel_auto = uc.auto_download if uc and uc.auto_download is not None else global_auto
|
||||
|
||||
if channel_auto:
|
||||
quality = user_settings.preferred_quality if user_settings else "best"
|
||||
from ..routers.downloads import _on_progress, _on_complete, _on_error
|
||||
for yt_id, vid_id in new_video_ids:
|
||||
existing_dl = db.query(Download).filter_by(
|
||||
user_id=user_id, video_id=vid_id
|
||||
).filter(Download.status.in_(["pending", "downloading", "complete"])).first()
|
||||
if not existing_dl:
|
||||
dl = Download(user_id=user_id, video_id=vid_id, status="pending")
|
||||
db.add(dl)
|
||||
db.flush()
|
||||
import threading
|
||||
t = threading.Thread(
|
||||
target=ytdlp.start_download,
|
||||
args=(yt_id, dl.id, _on_progress, _on_complete, _on_error, quality),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _discovery_task(user_id: int):
|
||||
from ..database import SessionLocal
|
||||
from ..services.discovery import run_full_discovery
|
||||
db = SessionLocal()
|
||||
try:
|
||||
run_full_discovery(db, user_id)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _enrich_missing_task(limit: int = 20):
|
||||
"""Fetch full metadata for videos that are missing a description."""
|
||||
from ..database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id FROM videos v
|
||||
WHERE v.description IS NULL
|
||||
ORDER BY
|
||||
-- prioritise: followed-channel videos first, then discovery queue, then rest
|
||||
(EXISTS (SELECT 1 FROM user_channels uc
|
||||
WHERE uc.channel_id = v.channel_id AND uc.status = 'followed')) DESC,
|
||||
(EXISTS (SELECT 1 FROM discovery_queue dq
|
||||
WHERE dq.channel_id = v.channel_id)) DESC,
|
||||
v.id DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"limit": limit},
|
||||
).mappings().all()
|
||||
for row in rows:
|
||||
try:
|
||||
meta = ytdlp.fetch_video_metadata(row["youtube_video_id"])
|
||||
if meta:
|
||||
vid = db.query(Video).filter_by(id=row["id"]).first()
|
||||
if vid:
|
||||
if meta.get("description") is not None:
|
||||
vid.description = meta["description"] or ""
|
||||
if not vid.tags and meta.get("tags"):
|
||||
vid.tags = meta["tags"]
|
||||
if not vid.category and meta.get("category"):
|
||||
vid.category = meta["category"]
|
||||
if not vid.chapters and meta.get("chapters"):
|
||||
vid.chapters = meta["chapters"]
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/feed", response_model=list[VideoOut])
|
||||
def channel_feed(
|
||||
limit: int = 24,
|
||||
offset: int = 0,
|
||||
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.thumbnail_url,
|
||||
v.duration_seconds, v.published_at,
|
||||
c.id AS channel_id, c.name AS channel_name, c.youtube_channel_id AS channel_youtube_id,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.watched, 0) AS is_watched,
|
||||
COALESCE(uv.queued, 0) AS queued
|
||||
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
|
||||
ORDER BY v.published_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"user_id": current_user.id, "limit": limit, "offset": offset},
|
||||
).mappings().all()
|
||||
return [VideoOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.post("/sync-all", status_code=202)
|
||||
def sync_all_channels(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
channels = db.execute(
|
||||
text("""
|
||||
SELECT c.id FROM channels c
|
||||
JOIN user_channels uc ON c.id = uc.channel_id
|
||||
WHERE uc.user_id = :uid AND uc.status = 'followed'
|
||||
"""),
|
||||
{"uid": current_user.id},
|
||||
).mappings().all()
|
||||
|
||||
for row in channels:
|
||||
background_tasks.add_task(_index_channel_task, row["id"], current_user.id)
|
||||
|
||||
if channels:
|
||||
background_tasks.add_task(_discovery_task, current_user.id)
|
||||
|
||||
background_tasks.add_task(_enrich_missing_task, 20)
|
||||
|
||||
return {"indexing": len(channels)}
|
||||
|
||||
|
||||
@router.post("/mark-seen", status_code=204)
|
||||
def mark_channels_seen(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
db.execute(
|
||||
text("UPDATE user_channels SET last_seen_at = :now WHERE user_id = :uid AND status = 'followed'"),
|
||||
{"now": datetime.utcnow(), "uid": current_user.id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ChannelOut])
|
||||
def list_channels(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = db.execute(
|
||||
text(_CHANNEL_STATS_SELECT + "ORDER BY last_published_at DESC"),
|
||||
{"user_id": current_user.id},
|
||||
).mappings().all()
|
||||
return [ChannelOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
# ── Channel Groups (must be before /{channel_id} to avoid route shadowing) ───
|
||||
|
||||
@router.get("/groups", response_model=list[ChannelGroupOut])
|
||||
def list_groups(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
groups = db.query(ChannelGroup).filter_by(user_id=current_user.id).all()
|
||||
result = []
|
||||
for g in groups:
|
||||
members = db.query(ChannelGroupMember).filter_by(group_id=g.id).all()
|
||||
result.append(ChannelGroupOut(id=g.id, name=g.name, channel_ids=[m.channel_id for m in members]))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/groups", response_model=ChannelGroupOut, status_code=201)
|
||||
def create_group(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
g = ChannelGroup(user_id=current_user.id, name=name)
|
||||
db.add(g)
|
||||
db.commit()
|
||||
db.refresh(g)
|
||||
return ChannelGroupOut(id=g.id, name=g.name, channel_ids=[])
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", status_code=204)
|
||||
def delete_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
g = db.query(ChannelGroup).filter_by(id=group_id, user_id=current_user.id).first()
|
||||
if not g:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
db.delete(g)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.patch("/groups/{group_id}", response_model=ChannelGroupOut)
|
||||
def rename_group(
|
||||
group_id: int,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
g = db.query(ChannelGroup).filter_by(id=group_id, user_id=current_user.id).first()
|
||||
if not g:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
name = (body.get("name") or "").strip()
|
||||
if name:
|
||||
g.name = name
|
||||
db.commit()
|
||||
members = db.query(ChannelGroupMember).filter_by(group_id=g.id).all()
|
||||
return ChannelGroupOut(id=g.id, name=g.name, channel_ids=[m.channel_id for m in members])
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/channels/{channel_id}", status_code=204)
|
||||
def add_channel_to_group(
|
||||
group_id: int,
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
g = db.query(ChannelGroup).filter_by(id=group_id, user_id=current_user.id).first()
|
||||
if not g:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
existing = db.query(ChannelGroupMember).filter_by(group_id=group_id, channel_id=channel_id).first()
|
||||
if not existing:
|
||||
db.add(ChannelGroupMember(group_id=group_id, channel_id=channel_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/channels/{channel_id}", status_code=204)
|
||||
def remove_channel_from_group_route(
|
||||
group_id: int,
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
m = db.query(ChannelGroupMember).filter_by(group_id=group_id, channel_id=channel_id).first()
|
||||
if m:
|
||||
db.delete(m)
|
||||
db.commit()
|
||||
|
||||
|
||||
class BulkChannelBody(BaseModel):
|
||||
channel_ids: list[int]
|
||||
action: str # "mute" | "unmute" | "unfollow"
|
||||
|
||||
|
||||
@router.post("/bulk-action", status_code=200)
|
||||
def bulk_channel_action(
|
||||
body: BulkChannelBody,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not body.channel_ids:
|
||||
return {"ok": True}
|
||||
placeholders = ",".join(str(int(i)) for i in body.channel_ids)
|
||||
if body.action == "mute":
|
||||
db.execute(
|
||||
text(f"""
|
||||
UPDATE user_channels SET muted_until = :until
|
||||
WHERE user_id = :user_id AND channel_id IN ({placeholders})
|
||||
"""),
|
||||
{"until": datetime.utcnow() + timedelta(days=30), "user_id": current_user.id},
|
||||
)
|
||||
elif body.action == "unmute":
|
||||
db.execute(
|
||||
text(f"UPDATE user_channels SET muted_until = NULL WHERE user_id = :user_id AND channel_id IN ({placeholders})"),
|
||||
{"user_id": current_user.id},
|
||||
)
|
||||
elif body.action == "unfollow":
|
||||
db.execute(
|
||||
text(f"DELETE FROM user_channels WHERE user_id = :user_id AND channel_id IN ({placeholders})"),
|
||||
{"user_id": current_user.id},
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/{channel_id}", response_model=ChannelOut)
|
||||
def get_channel(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
row = db.execute(
|
||||
text("""
|
||||
SELECT c.*, uc.status, uc.auto_download, uc.muted_until,
|
||||
(SELECT COUNT(*) FROM videos WHERE channel_id = c.id) AS video_count,
|
||||
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_published_at,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND COALESCE(uv.watched, 0) = 0) AS unwatched_count,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND uv.watched = 1) AS watched_count,
|
||||
(SELECT COUNT(*) FROM videos v
|
||||
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = c.id AND uv.downloaded = 1) AS downloaded_count,
|
||||
0 AS new_count,
|
||||
(SELECT v.youtube_video_id FROM videos v
|
||||
WHERE v.channel_id = c.id ORDER BY v.published_at DESC LIMIT 1) AS latest_video_id,
|
||||
(SELECT v.title FROM videos v
|
||||
WHERE v.channel_id = c.id ORDER BY v.published_at DESC LIMIT 1) AS latest_video_title
|
||||
FROM channels c
|
||||
LEFT JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :user_id
|
||||
WHERE c.id = :channel_id
|
||||
"""),
|
||||
{"user_id": current_user.id, "channel_id": channel_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return ChannelOut(**dict(row))
|
||||
|
||||
|
||||
@router.get("/{channel_id}/videos", response_model=list[VideoOut])
|
||||
def get_channel_videos(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_get_channel_or_404(db, channel_id)
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.watched, 0) AS is_watched
|
||||
FROM videos v
|
||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||
WHERE v.channel_id = :channel_id
|
||||
ORDER BY v.published_at DESC
|
||||
"""),
|
||||
{"user_id": current_user.id, "channel_id": channel_id},
|
||||
).mappings().all()
|
||||
return [VideoOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{channel_id}/follow", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def follow_channel(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_get_channel_or_404(db, channel_id)
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if uc:
|
||||
uc.status = "followed"
|
||||
else:
|
||||
db.add(UserChannel(user_id=current_user.id, channel_id=channel_id, status="followed"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.delete("/{channel_id}/follow", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def unfollow_channel(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if uc:
|
||||
db.delete(uc)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.patch("/{channel_id}/auto-download", status_code=200)
|
||||
def set_channel_auto_download(
|
||||
channel_id: int,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if not uc:
|
||||
raise HTTPException(status_code=404, detail="Not following this channel")
|
||||
value = body.get("auto_download") # True / False / None
|
||||
uc.auto_download = value
|
||||
db.commit()
|
||||
return {"auto_download": uc.auto_download}
|
||||
|
||||
|
||||
@router.post("/{channel_id}/index", status_code=status.HTTP_202_ACCEPTED)
|
||||
def index_channel(
|
||||
channel_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_get_channel_or_404(db, channel_id)
|
||||
background_tasks.add_task(_index_channel_task, channel_id, current_user.id)
|
||||
return {"detail": "Indexing started"}
|
||||
|
||||
|
||||
@router.post("/follow-bulk", status_code=200)
|
||||
def follow_bulk(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Follow a large list of channel handles/IDs without hitting YouTube.
|
||||
|
||||
Creates stub Channel records for unknowns and UserChannel rows immediately.
|
||||
Metadata (name, thumbnail, videos) fills in when the user hits Sync All.
|
||||
"""
|
||||
handles = body.get("handles", [])
|
||||
if not handles or not isinstance(handles, list):
|
||||
raise HTTPException(status_code=400, detail="handles list required")
|
||||
|
||||
followed = 0
|
||||
already = 0
|
||||
created = 0
|
||||
|
||||
for handle in handles:
|
||||
handle = str(handle).strip()
|
||||
if not handle:
|
||||
continue
|
||||
|
||||
channel = db.query(Channel).filter_by(youtube_channel_id=handle).first()
|
||||
if not channel:
|
||||
# Stub — name defaults to handle, filled in on next index
|
||||
channel = Channel(
|
||||
youtube_channel_id=handle,
|
||||
name=handle.lstrip("@"),
|
||||
)
|
||||
db.add(channel)
|
||||
db.flush()
|
||||
created += 1
|
||||
|
||||
uc = db.query(UserChannel).filter_by(
|
||||
user_id=current_user.id, channel_id=channel.id
|
||||
).first()
|
||||
if uc:
|
||||
if uc.status != "followed":
|
||||
uc.status = "followed"
|
||||
followed += 1
|
||||
else:
|
||||
already += 1
|
||||
else:
|
||||
db.add(UserChannel(
|
||||
user_id=current_user.id,
|
||||
channel_id=channel.id,
|
||||
status="followed",
|
||||
))
|
||||
followed += 1
|
||||
|
||||
db.commit()
|
||||
return {"followed": followed, "already_following": already, "new_channels": created}
|
||||
|
||||
|
||||
@router.patch("/{channel_id}/notes", status_code=200)
|
||||
def update_channel_notes(
|
||||
channel_id: int,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if not uc:
|
||||
raise HTTPException(status_code=404, detail="Not following this channel")
|
||||
uc.notes = body.get("notes", "") or ""
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{channel_id}/mute", status_code=204)
|
||||
def mute_channel(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if not uc:
|
||||
raise HTTPException(status_code=404, detail="Not following this channel")
|
||||
uc.muted_until = datetime.utcnow() + timedelta(days=30)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.delete("/{channel_id}/mute", status_code=204)
|
||||
def unmute_channel(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if uc:
|
||||
uc.muted_until = None
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/follow-by-url", status_code=status.HTTP_201_CREATED)
|
||||
def follow_by_url(
|
||||
body: dict,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
yt_channel_id = body.get("youtube_channel_id") or body.get("channel_id")
|
||||
if not yt_channel_id:
|
||||
raise HTTPException(status_code=400, detail="youtube_channel_id required")
|
||||
|
||||
channel = db.query(Channel).filter_by(youtube_channel_id=yt_channel_id).first()
|
||||
if not channel:
|
||||
meta = ytdlp.fetch_channel_metadata(yt_channel_id, max_videos=30)
|
||||
if not meta or not meta.get("channel"):
|
||||
raise HTTPException(status_code=404, detail="Channel not found on YouTube")
|
||||
ch_data = meta["channel"]
|
||||
channel = Channel(**{k: v for k, v in ch_data.items() if hasattr(Channel, k)})
|
||||
channel.crawled_at = datetime.utcnow()
|
||||
db.add(channel)
|
||||
db.flush()
|
||||
|
||||
for vdata in meta.get("videos", []):
|
||||
yt_id = vdata.get("youtube_video_id")
|
||||
if not yt_id:
|
||||
continue
|
||||
if not db.query(Video).filter_by(youtube_video_id=yt_id).first():
|
||||
db.add(Video(
|
||||
youtube_video_id=yt_id,
|
||||
channel_id=channel.id,
|
||||
title=vdata.get("title", ""),
|
||||
description=vdata.get("description"),
|
||||
thumbnail_url=vdata.get("thumbnail_url"),
|
||||
duration_seconds=vdata.get("duration_seconds"),
|
||||
published_at=vdata.get("published_at"),
|
||||
tags=vdata.get("tags"),
|
||||
category=vdata.get("category"),
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(channel)
|
||||
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel.id).first()
|
||||
if uc:
|
||||
uc.status = "followed"
|
||||
else:
|
||||
db.add(UserChannel(user_id=current_user.id, channel_id=channel.id, status="followed"))
|
||||
db.commit()
|
||||
|
||||
background_tasks.add_task(_discovery_task, current_user.id)
|
||||
|
||||
return {"channel_id": channel.id, "name": channel.name}
|
||||
178
backend/routers/collections.py
Normal file
178
backend/routers/collections.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import Collection, CollectionItem, User, Video
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CollectionOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
created_at: datetime
|
||||
video_count: int = 0
|
||||
thumbnails: list[str] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.get("", response_model=list[CollectionOut])
|
||||
def list_collections(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT c.id, c.name, c.created_at,
|
||||
COUNT(ci.id) AS video_count
|
||||
FROM collections c
|
||||
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
||||
WHERE c.user_id = :uid
|
||||
GROUP BY c.id
|
||||
ORDER BY c.created_at DESC
|
||||
"""),
|
||||
{"uid": current_user.id},
|
||||
).mappings().all()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
# Grab up to 4 thumbnails for mosaic preview
|
||||
thumbs = db.execute(
|
||||
text("""
|
||||
SELECT v.thumbnail_url FROM collection_items ci
|
||||
JOIN videos v ON ci.video_id = v.id
|
||||
WHERE ci.collection_id = :cid AND v.thumbnail_url IS NOT NULL
|
||||
ORDER BY ci.added_at DESC LIMIT 4
|
||||
"""),
|
||||
{"cid": row["id"]},
|
||||
).scalars().all()
|
||||
result.append(CollectionOut(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
created_at=row["created_at"],
|
||||
video_count=row["video_count"],
|
||||
thumbnails=list(thumbs),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=CollectionOut, status_code=201)
|
||||
def create_collection(
|
||||
body: CollectionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Name required")
|
||||
col = Collection(user_id=current_user.id, name=name)
|
||||
db.add(col)
|
||||
db.commit()
|
||||
db.refresh(col)
|
||||
return CollectionOut(id=col.id, name=col.name, created_at=col.created_at, video_count=0, thumbnails=[])
|
||||
|
||||
|
||||
@router.patch("/{collection_id}", response_model=CollectionOut)
|
||||
def rename_collection(
|
||||
collection_id: int,
|
||||
body: CollectionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
||||
if not col:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
col.name = (body.name or "").strip() or col.name
|
||||
db.commit()
|
||||
count = db.query(CollectionItem).filter_by(collection_id=col.id).count()
|
||||
return CollectionOut(id=col.id, name=col.name, created_at=col.created_at, video_count=count)
|
||||
|
||||
|
||||
@router.delete("/{collection_id}", status_code=204)
|
||||
def delete_collection(
|
||||
collection_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
||||
if col:
|
||||
db.delete(col)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{collection_id}/videos")
|
||||
def get_collection_videos(
|
||||
collection_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
||||
if not col:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at, v.description,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
COALESCE(uv.watched, 0) AS is_watched,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.liked, 0) AS liked,
|
||||
COALESCE(uv.queued, 0) AS queued,
|
||||
ci.added_at
|
||||
FROM collection_items ci
|
||||
JOIN videos v ON ci.video_id = v.id
|
||||
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 = :uid
|
||||
WHERE ci.collection_id = :cid
|
||||
ORDER BY ci.added_at DESC
|
||||
"""),
|
||||
{"uid": current_user.id, "cid": collection_id},
|
||||
).mappings().all()
|
||||
return {"collection": {"id": col.id, "name": col.name}, "videos": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/videos", status_code=201)
|
||||
def add_to_collection(
|
||||
collection_id: int,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
||||
if not col:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
video_id = body.get("video_id")
|
||||
if not video_id:
|
||||
raise HTTPException(status_code=400, detail="video_id required")
|
||||
existing = db.query(CollectionItem).filter_by(collection_id=collection_id, video_id=video_id).first()
|
||||
if not existing:
|
||||
db.add(CollectionItem(collection_id=collection_id, video_id=video_id))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/videos/{video_id}", status_code=204)
|
||||
def remove_from_collection(
|
||||
collection_id: int,
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
col = db.query(Collection).filter_by(id=collection_id, user_id=current_user.id).first()
|
||||
if not col:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
item = db.query(CollectionItem).filter_by(collection_id=collection_id, video_id=video_id).first()
|
||||
if item:
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
239
backend/routers/discovery.py
Normal file
239
backend/routers/discovery.py
Normal file
@@ -0,0 +1,239 @@
|
||||
import json
|
||||
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 ..database import get_db
|
||||
from ..models import Channel, DiscoveryQueue, User, UserChannel, UserSettings
|
||||
from ..services.discovery import run_full_discovery
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PreviewVideo(BaseModel):
|
||||
thumbnail_url: str
|
||||
title: str
|
||||
|
||||
|
||||
class DiscoveryItem(BaseModel):
|
||||
id: int
|
||||
channel_id: int
|
||||
youtube_channel_id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
thumbnail_url: Optional[str]
|
||||
subscriber_count: Optional[int] = None
|
||||
score: float
|
||||
source: Optional[str]
|
||||
recent_video_titles: list[str] = []
|
||||
preview_videos: list[PreviewVideo] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("", response_model=list[DiscoveryItem])
|
||||
def list_discovery(
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT dq.id, dq.channel_id, dq.score, dq.source, dq.preview_json,
|
||||
c.youtube_channel_id, c.name, c.description, c.thumbnail_url, c.subscriber_count
|
||||
FROM discovery_queue dq
|
||||
JOIN channels c ON dq.channel_id = c.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')
|
||||
)
|
||||
ORDER BY dq.score DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"user_id": current_user.id, "limit": limit, "offset": offset},
|
||||
).mappings().all()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
row = dict(row)
|
||||
video_rows = db.execute(
|
||||
text("""
|
||||
SELECT title, youtube_video_id FROM videos
|
||||
WHERE channel_id = :channel_id
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 3
|
||||
"""),
|
||||
{"channel_id": row["channel_id"]},
|
||||
).fetchall()
|
||||
row["recent_video_titles"] = [r[0] for r in video_rows]
|
||||
|
||||
if video_rows:
|
||||
row["preview_videos"] = [
|
||||
{
|
||||
"thumbnail_url": f"https://i.ytimg.com/vi/{r[1]}/hqdefault.jpg",
|
||||
"title": r[0],
|
||||
}
|
||||
for r in video_rows
|
||||
]
|
||||
else:
|
||||
try:
|
||||
row["preview_videos"] = json.loads(row.get("preview_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
row["preview_videos"] = []
|
||||
|
||||
items.append(DiscoveryItem(**row))
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/{channel_id}/follow", status_code=204)
|
||||
def follow_discovery(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
channel = db.query(Channel).filter(Channel.id == channel_id).first()
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if uc:
|
||||
uc.status = "followed"
|
||||
else:
|
||||
db.add(UserChannel(user_id=current_user.id, channel_id=channel_id, status="followed"))
|
||||
|
||||
dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if dq:
|
||||
dq.seen = True
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{channel_id}/dismiss", status_code=204)
|
||||
def dismiss_discovery(
|
||||
channel_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if uc:
|
||||
uc.status = "dismissed"
|
||||
else:
|
||||
db.add(UserChannel(user_id=current_user.id, channel_id=channel_id, status="dismissed"))
|
||||
|
||||
dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if dq:
|
||||
dq.seen = True
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/refresh", status_code=202)
|
||||
def refresh_discovery(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
s = db.query(UserSettings).filter_by(user_id=current_user.id).first()
|
||||
regions_str = (s.discovery_regions if s and s.discovery_regions else "US,SE")
|
||||
regions = [r.strip().upper() for r in regions_str.split(",") if r.strip()]
|
||||
background_tasks.add_task(run_full_discovery, db, current_user.id, regions)
|
||||
from .channels import _enrich_missing_task
|
||||
background_tasks.add_task(_enrich_missing_task, 20)
|
||||
return {"detail": "Discovery refresh started"}
|
||||
|
||||
|
||||
@router.get("/videos", response_model=list[dict])
|
||||
def discovery_videos(
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""2 recent videos from every channel in the discovery queue that has indexed content.
|
||||
Ordered by channel score so the best-matched channels surface first.
|
||||
Channels fall out naturally when dismissed or followed."""
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT * FROM (
|
||||
SELECT v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at,
|
||||
c.id AS channel_id, c.name AS channel_name,
|
||||
c.youtube_channel_id AS channel_youtube_id,
|
||||
dq.score,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.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
|
||||
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')
|
||||
)
|
||||
)
|
||||
WHERE rn <= 2
|
||||
ORDER BY score DESC, rn ASC, RANDOM()
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"user_id": current_user.id, "limit": limit, "offset": offset},
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/videos/{youtube_video_id}/dismiss", status_code=204)
|
||||
def dismiss_discovery_video(
|
||||
youtube_video_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Dismiss all discovery for the channel that owns this video."""
|
||||
from ..models import Video
|
||||
video = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first()
|
||||
if not video or not video.channel_id:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
|
||||
channel_id = video.channel_id
|
||||
uc = db.query(UserChannel).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if uc:
|
||||
uc.status = "dismissed"
|
||||
else:
|
||||
db.add(UserChannel(user_id=current_user.id, channel_id=channel_id, status="dismissed"))
|
||||
|
||||
dq = db.query(DiscoveryQueue).filter_by(user_id=current_user.id, channel_id=channel_id).first()
|
||||
if dq:
|
||||
dq.seen = True
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/community", response_model=list[dict])
|
||||
def community_shelf(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Channels downloaded by other users, weighted by count."""
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT c.id, c.youtube_channel_id, c.name, c.thumbnail_url,
|
||||
COUNT(DISTINCT d.user_id) AS downloader_count,
|
||||
v.title AS latest_title, v.thumbnail_url AS latest_thumbnail
|
||||
FROM downloads d
|
||||
JOIN videos v ON d.video_id = v.id
|
||||
JOIN channels c ON v.channel_id = c.id
|
||||
WHERE d.user_id != :user_id
|
||||
AND d.status = 'complete'
|
||||
AND v.id NOT IN (
|
||||
SELECT uv.video_id FROM user_videos uv
|
||||
WHERE uv.user_id = :user_id AND (uv.watched = 1 OR uv.downloaded = 1)
|
||||
)
|
||||
GROUP BY c.id
|
||||
ORDER BY downloader_count DESC
|
||||
LIMIT 20
|
||||
"""),
|
||||
{"user_id": current_user.id},
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
362
backend/routers/downloads.py
Normal file
362
backend/routers/downloads.py
Normal file
@@ -0,0 +1,362 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
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, SessionLocal
|
||||
from ..models import Channel, Download, User, UserSettings, UserVideo, Video
|
||||
from ..services import ytdlp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DownloadRequest(BaseModel):
|
||||
youtube_video_id: str
|
||||
quality: Optional[str] = None
|
||||
|
||||
|
||||
TRASH_TTL_DAYS = 7
|
||||
|
||||
class DownloadOut(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
progress_percent: float
|
||||
video_title: Optional[str]
|
||||
video_thumbnail_url: Optional[str]
|
||||
youtube_video_id: Optional[str]
|
||||
file_url: Optional[str]
|
||||
resolution: Optional[str]
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime]
|
||||
error_message: Optional[str]
|
||||
pending_delete_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
def _on_progress(download_id: int, pct: float):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
dl = db.query(Download).filter(Download.id == download_id).first()
|
||||
if dl:
|
||||
dl.progress_percent = pct
|
||||
dl.status = "downloading"
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _on_complete(download_id: int, file_path: Optional[str], resolution: Optional[str] = None):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
dl = db.query(Download).filter(Download.id == download_id).first()
|
||||
if dl:
|
||||
dl.status = "complete"
|
||||
dl.progress_percent = 100.0
|
||||
dl.completed_at = datetime.utcnow()
|
||||
dl.file_path = file_path
|
||||
dl.resolution = resolution
|
||||
db.commit()
|
||||
|
||||
uv = db.query(UserVideo).filter_by(user_id=dl.user_id, video_id=dl.video_id).first()
|
||||
if not uv:
|
||||
uv = UserVideo(user_id=dl.user_id, video_id=dl.video_id)
|
||||
db.add(uv)
|
||||
uv.downloaded = True
|
||||
uv.downloaded_at = datetime.utcnow()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _on_error(download_id: int, message: str):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
dl = db.query(Download).filter(Download.id == download_id).first()
|
||||
if dl:
|
||||
dl.status = "failed"
|
||||
dl.error_message = message
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _ensure_video(db: Session, youtube_video_id: str) -> Video:
|
||||
video = db.query(Video).filter_by(youtube_video_id=youtube_video_id).first()
|
||||
if video:
|
||||
return video
|
||||
|
||||
meta = ytdlp.fetch_video_metadata(youtube_video_id)
|
||||
if not meta:
|
||||
raise HTTPException(status_code=404, detail="Video not found on YouTube")
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
db.commit()
|
||||
db.refresh(video)
|
||||
return video
|
||||
|
||||
|
||||
@router.post("", response_model=DownloadOut, status_code=201)
|
||||
def create_download(
|
||||
body: DownloadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
video = _ensure_video(db, body.youtube_video_id)
|
||||
user_settings = db.query(UserSettings).filter_by(user_id=current_user.id).first()
|
||||
default_quality = user_settings.preferred_quality if user_settings else "best"
|
||||
quality = body.quality if body.quality in ytdlp.QUALITY_FORMATS else default_quality
|
||||
|
||||
_DL_SELECT = """
|
||||
SELECT d.id, d.status, d.progress_percent, d.resolution,
|
||||
d.created_at, d.completed_at, d.error_message, d.pending_delete_at,
|
||||
v.title AS video_title, v.thumbnail_url AS video_thumbnail_url,
|
||||
v.youtube_video_id,
|
||||
'/files/' || v.youtube_video_id || '.mp4' AS file_url
|
||||
FROM downloads d JOIN videos v ON d.video_id = v.id
|
||||
WHERE d.id = :id
|
||||
"""
|
||||
|
||||
existing = db.query(Download).filter_by(
|
||||
user_id=current_user.id,
|
||||
video_id=video.id,
|
||||
).filter(Download.status.in_(["pending", "downloading", "complete"])).first()
|
||||
if existing:
|
||||
row = db.execute(text(_DL_SELECT), {"id": existing.id}).mappings().first()
|
||||
return DownloadOut(**dict(row))
|
||||
|
||||
dl = Download(user_id=current_user.id, video_id=video.id, status="pending")
|
||||
db.add(dl)
|
||||
db.commit()
|
||||
db.refresh(dl)
|
||||
|
||||
background_tasks.add_task(
|
||||
ytdlp.start_download,
|
||||
video.youtube_video_id, dl.id,
|
||||
_on_progress, _on_complete, _on_error,
|
||||
quality,
|
||||
)
|
||||
|
||||
row = db.execute(text(_DL_SELECT), {"id": dl.id}).mappings().first()
|
||||
return DownloadOut(**dict(row))
|
||||
|
||||
|
||||
@router.get("", response_model=list[DownloadOut])
|
||||
def list_downloads(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_purge_expired_trash(db)
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT d.id, d.status, d.progress_percent, d.created_at, d.completed_at,
|
||||
d.error_message, d.pending_delete_at, d.resolution,
|
||||
v.title AS video_title, v.thumbnail_url AS video_thumbnail_url,
|
||||
v.youtube_video_id,
|
||||
'/files/' || v.youtube_video_id || '.mp4' AS file_url
|
||||
FROM downloads d JOIN videos v ON d.video_id = v.id
|
||||
WHERE d.user_id = :user_id
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 200
|
||||
"""),
|
||||
{"user_id": current_user.id},
|
||||
).mappings().all()
|
||||
return [DownloadOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
def _get_quality(db, user_id: int) -> str:
|
||||
s = db.query(UserSettings).filter_by(user_id=user_id).first()
|
||||
return s.preferred_quality if s else "best"
|
||||
|
||||
|
||||
@router.post("/channel/{channel_id}", status_code=202)
|
||||
def download_channel_videos(
|
||||
channel_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
quality = _get_quality(db, current_user.id)
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id
|
||||
FROM videos v
|
||||
LEFT JOIN downloads d ON v.id = d.video_id AND d.user_id = :uid
|
||||
AND d.status IN ('pending', 'downloading', 'complete')
|
||||
WHERE v.channel_id = :cid AND d.id IS NULL
|
||||
"""),
|
||||
{"uid": current_user.id, "cid": channel_id},
|
||||
).mappings().all()
|
||||
|
||||
count = 0
|
||||
for row in rows:
|
||||
dl = Download(user_id=current_user.id, video_id=row["id"], status="pending")
|
||||
db.add(dl)
|
||||
db.flush()
|
||||
background_tasks.add_task(
|
||||
ytdlp.start_download, row["youtube_video_id"], dl.id,
|
||||
_on_progress, _on_complete, _on_error, quality,
|
||||
)
|
||||
count += 1
|
||||
db.commit()
|
||||
return {"queued": count}
|
||||
|
||||
|
||||
@router.post("/following", status_code=202)
|
||||
def download_following_videos(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
quality = _get_quality(db, current_user.id)
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT v.id, v.youtube_video_id
|
||||
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 = :uid AND uc.status = 'followed'
|
||||
LEFT JOIN downloads d ON v.id = d.video_id AND d.user_id = :uid
|
||||
AND d.status IN ('pending', 'downloading', 'complete')
|
||||
WHERE d.id IS NULL
|
||||
"""),
|
||||
{"uid": current_user.id},
|
||||
).mappings().all()
|
||||
|
||||
count = 0
|
||||
for row in rows:
|
||||
dl = Download(user_id=current_user.id, video_id=row["id"], status="pending")
|
||||
db.add(dl)
|
||||
db.flush()
|
||||
background_tasks.add_task(
|
||||
ytdlp.start_download, row["youtube_video_id"], dl.id,
|
||||
_on_progress, _on_complete, _on_error, quality,
|
||||
)
|
||||
count += 1
|
||||
db.commit()
|
||||
return {"queued": count}
|
||||
|
||||
|
||||
def _purge_expired_trash(db: Session):
|
||||
expired = db.execute(
|
||||
text("SELECT id, video_id, user_id FROM downloads WHERE pending_delete_at IS NOT NULL AND pending_delete_at <= :now"),
|
||||
{"now": datetime.utcnow()},
|
||||
).mappings().all()
|
||||
for row in expired:
|
||||
video = db.query(Video).filter_by(id=row["video_id"]).first()
|
||||
if video:
|
||||
fp = ytdlp.predicted_file_path(video.youtube_video_id)
|
||||
if fp.exists():
|
||||
try:
|
||||
os.remove(fp)
|
||||
except OSError:
|
||||
pass
|
||||
uv = db.query(UserVideo).filter_by(user_id=row["user_id"], video_id=row["video_id"]).first()
|
||||
if uv:
|
||||
uv.downloaded = False
|
||||
uv.downloaded_at = None
|
||||
db.execute(text("DELETE FROM downloads WHERE id = :id"), {"id": row["id"]})
|
||||
if expired:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _delete_download_record(db: Session, dl: "Download", user_id: int):
|
||||
video = db.query(Video).filter_by(id=dl.video_id).first()
|
||||
if video:
|
||||
fp = ytdlp.predicted_file_path(video.youtube_video_id)
|
||||
if fp.exists():
|
||||
try:
|
||||
os.remove(fp)
|
||||
except OSError:
|
||||
pass
|
||||
uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=dl.video_id).first()
|
||||
if uv:
|
||||
uv.downloaded = False
|
||||
uv.downloaded_at = None
|
||||
db.delete(dl)
|
||||
|
||||
|
||||
@router.delete("/all", status_code=204)
|
||||
def delete_all_downloads(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
dls = db.query(Download).filter(
|
||||
Download.user_id == current_user.id,
|
||||
Download.status.notin_(["pending", "downloading"]),
|
||||
).all()
|
||||
for dl in dls:
|
||||
_delete_download_record(db, dl, current_user.id)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{download_id}/restore", status_code=200)
|
||||
def restore_download(
|
||||
download_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
dl = db.query(Download).filter_by(id=download_id, user_id=current_user.id).first()
|
||||
if not dl:
|
||||
raise HTTPException(status_code=404, detail="Download not found")
|
||||
dl.pending_delete_at = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{download_id}", status_code=204)
|
||||
def delete_download(
|
||||
download_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a download record and its file from disk. Resets downloaded flag on the video."""
|
||||
dl = db.query(Download).filter_by(id=download_id, user_id=current_user.id).first()
|
||||
if not dl:
|
||||
raise HTTPException(status_code=404, detail="Download not found")
|
||||
_delete_download_record(db, dl, current_user.id)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{download_id}", response_model=DownloadOut)
|
||||
def get_download(
|
||||
download_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
row = db.execute(
|
||||
text("""
|
||||
SELECT d.id, d.status, d.progress_percent, d.resolution,
|
||||
d.created_at, d.completed_at, d.error_message, d.pending_delete_at,
|
||||
v.title AS video_title, v.thumbnail_url AS video_thumbnail_url,
|
||||
v.youtube_video_id,
|
||||
'/files/' || v.youtube_video_id || '.mp4' AS file_url
|
||||
FROM downloads d JOIN videos v ON d.video_id = v.id
|
||||
WHERE d.id = :id AND d.user_id = :user_id
|
||||
"""),
|
||||
{"id": download_id, "user_id": current_user.id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Download not found")
|
||||
return DownloadOut(**dict(row))
|
||||
81
backend/routers/export.py
Normal file
81
backend/routers/export.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def export_data(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uid = current_user.id
|
||||
|
||||
watch_history = db.execute(text("""
|
||||
SELECT v.youtube_video_id, v.title, c.name AS channel_name,
|
||||
uv.watch_progress_seconds, uv.last_watched_at
|
||||
FROM user_videos uv
|
||||
JOIN videos v ON v.id = uv.video_id
|
||||
LEFT JOIN channels c ON c.id = v.channel_id
|
||||
WHERE uv.user_id = :uid AND uv.watched = 1
|
||||
ORDER BY uv.last_watched_at DESC
|
||||
"""), {"uid": uid}).mappings().all()
|
||||
|
||||
ratings = db.execute(text("""
|
||||
SELECT v.youtube_video_id, v.title, c.name AS channel_name, uv.rating
|
||||
FROM user_videos uv
|
||||
JOIN videos v ON v.id = uv.video_id
|
||||
LEFT JOIN channels c ON c.id = v.channel_id
|
||||
WHERE uv.user_id = :uid AND uv.rating IS NOT NULL
|
||||
ORDER BY v.title
|
||||
"""), {"uid": uid}).mappings().all()
|
||||
|
||||
liked = db.execute(text("""
|
||||
SELECT v.youtube_video_id, v.title, c.name AS channel_name, uv.liked_at
|
||||
FROM user_videos uv
|
||||
JOIN videos v ON v.id = uv.video_id
|
||||
LEFT JOIN channels c ON c.id = v.channel_id
|
||||
WHERE uv.user_id = :uid AND uv.liked = 1
|
||||
ORDER BY uv.liked_at DESC
|
||||
"""), {"uid": uid}).mappings().all()
|
||||
|
||||
bookmarks = db.execute(text("""
|
||||
SELECT v.youtube_video_id, v.title, c.name AS channel_name,
|
||||
vb.timestamp_seconds, vb.note, vb.created_at
|
||||
FROM video_bookmarks vb
|
||||
JOIN videos v ON v.id = vb.video_id
|
||||
LEFT JOIN channels c ON c.id = v.channel_id
|
||||
WHERE vb.user_id = :uid
|
||||
ORDER BY vb.created_at DESC
|
||||
"""), {"uid": uid}).mappings().all()
|
||||
|
||||
queue = db.execute(text("""
|
||||
SELECT v.youtube_video_id, v.title, c.name AS channel_name
|
||||
FROM user_videos uv
|
||||
JOIN videos v ON v.id = uv.video_id
|
||||
LEFT JOIN channels c ON c.id = v.channel_id
|
||||
WHERE uv.user_id = :uid AND uv.queued = 1
|
||||
ORDER BY v.title
|
||||
"""), {"uid": uid}).mappings().all()
|
||||
|
||||
payload = {
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"username": current_user.username,
|
||||
"watch_history": [dict(r) for r in watch_history],
|
||||
"ratings": [dict(r) for r in ratings],
|
||||
"liked": [dict(r) for r in liked],
|
||||
"bookmarks": [dict(r) for r in bookmarks],
|
||||
"queue": [dict(r) for r in queue],
|
||||
}
|
||||
|
||||
return JSONResponse(
|
||||
content=payload,
|
||||
headers={"Content-Disposition": f"attachment; filename=ythub-export-{datetime.utcnow().strftime('%Y%m%d')}.json"},
|
||||
)
|
||||
332
backend/routers/search.py
Normal file
332
backend/routers/search.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Two-tier search: local FTS5 first, yt-dlp live fallback."""
|
||||
import json
|
||||
import re as _re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import User, Video, Channel, UserVideo, SearchHistory, UserTagAffinity
|
||||
from ..services import ytdlp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_STOPWORDS = {
|
||||
"the","a","an","is","it","in","on","at","to","of","and","or","for",
|
||||
"with","this","that","are","was","be","by","as","from","has","have",
|
||||
"will","can","but","not","my","i","you","your","we","how","what",
|
||||
"why","when","which","who","more","about","than","do","did","does",
|
||||
}
|
||||
|
||||
def _query_affinity_tags(q: str) -> list[str]:
|
||||
words = _re.sub(r"[^\w\s]", "", q.lower()).split()
|
||||
return [w for w in words if len(w) >= 3 and w not in _STOPWORDS]
|
||||
|
||||
|
||||
def _log_search(db: Session, user_id: int, q: str):
|
||||
"""Persist search query and bump affinity scores for its meaningful terms."""
|
||||
db.add(SearchHistory(user_id=user_id, query=q.strip()))
|
||||
for tag in _query_affinity_tags(q):
|
||||
existing = db.query(UserTagAffinity).filter_by(user_id=user_id, tag=tag).first()
|
||||
if existing:
|
||||
existing.score = min(existing.score + 0.3, 50.0)
|
||||
existing.updated_at = datetime.utcnow()
|
||||
else:
|
||||
db.add(UserTagAffinity(user_id=user_id, tag=tag, score=0.3,
|
||||
updated_at=datetime.utcnow()))
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
|
||||
class VideoResult(BaseModel):
|
||||
youtube_video_id: str
|
||||
title: str
|
||||
thumbnail_url: Optional[str]
|
||||
duration_seconds: Optional[int]
|
||||
channel_name: str
|
||||
channel_youtube_id: Optional[str]
|
||||
published_at: Optional[datetime]
|
||||
is_local: bool
|
||||
is_downloaded: bool
|
||||
is_watched: bool
|
||||
local_video_id: Optional[int]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ChannelResult(BaseModel):
|
||||
youtube_channel_id: str
|
||||
name: str
|
||||
thumbnail_url: Optional[str]
|
||||
description: Optional[str]
|
||||
is_followed: bool
|
||||
local_channel_id: Optional[int]
|
||||
subscriber_count: Optional[int] = None
|
||||
video_count: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
videos: list[VideoResult]
|
||||
channels: list[ChannelResult]
|
||||
source: str # "local" | "live" | "mixed"
|
||||
query: str
|
||||
|
||||
|
||||
def _sanitize_fts(q: str) -> str:
|
||||
"""Strip FTS5 syntax characters and return a safe multi-word query."""
|
||||
clean = _re.sub(r'["\(\)\[\]\{\}\*\+\?\!\^\~\-]', ' ', q)
|
||||
words = [w for w in clean.split() if w.upper() not in ("AND", "OR", "NOT")]
|
||||
if not words:
|
||||
return '""'
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def _local_video_search(db: Session, user_id: int, q: str, limit: int = 100) -> list[dict]:
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
v.id, v.youtube_video_id, v.title, v.thumbnail_url,
|
||||
v.duration_seconds, v.published_at,
|
||||
c.name AS channel_name, c.youtube_channel_id,
|
||||
COALESCE(uv.downloaded, 0) AS is_downloaded,
|
||||
COALESCE(uv.watched, 0) AS is_watched
|
||||
FROM videos_fts fts
|
||||
JOIN videos v ON fts.rowid = v.id
|
||||
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 videos_fts MATCH :query
|
||||
ORDER BY rank
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"user_id": user_id, "query": _sanitize_fts(q), "limit": limit},
|
||||
).mappings().all()
|
||||
except Exception:
|
||||
return []
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _local_channel_search(db: Session, user_id: int, q: str, limit: int = 5) -> list[dict]:
|
||||
try:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
c.id, c.youtube_channel_id, c.name, c.thumbnail_url, c.description,
|
||||
c.subscriber_count,
|
||||
CASE WHEN uc.status = 'followed' THEN 1 ELSE 0 END AS is_followed,
|
||||
(SELECT COUNT(*) FROM videos WHERE channel_id = c.id) AS video_count
|
||||
FROM channels_fts fts
|
||||
JOIN channels c ON fts.rowid = c.id
|
||||
LEFT JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :user_id
|
||||
WHERE channels_fts MATCH :query
|
||||
ORDER BY rank
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"user_id": user_id, "query": _sanitize_fts(q), "limit": limit},
|
||||
).mappings().all()
|
||||
except Exception:
|
||||
return []
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _upsert_channel_from_meta(db: Session, ch: dict) -> Channel:
|
||||
existing = db.query(Channel).filter_by(youtube_channel_id=ch["youtube_channel_id"]).first()
|
||||
if not existing:
|
||||
existing = Channel(**{k: v for k, v in ch.items() if hasattr(Channel, k)})
|
||||
db.add(existing)
|
||||
db.flush()
|
||||
return existing
|
||||
|
||||
|
||||
def _live_search_to_results(
|
||||
db: Session, user_id: int, raw: list[dict]
|
||||
) -> list[VideoResult]:
|
||||
results = []
|
||||
for item in raw:
|
||||
yt_id = item.get("youtube_video_id")
|
||||
if not yt_id:
|
||||
continue
|
||||
local = db.query(Video).filter_by(youtube_video_id=yt_id).first()
|
||||
uv = None
|
||||
if local:
|
||||
uv = db.query(UserVideo).filter_by(user_id=user_id, video_id=local.id).first()
|
||||
|
||||
ch = item.get("channel", {}) or {}
|
||||
# Prefer the DB date — flat-playlist search results rarely include upload_date
|
||||
published_at = (local.published_at if local and local.published_at
|
||||
else item.get("published_at"))
|
||||
results.append(VideoResult(
|
||||
youtube_video_id=yt_id,
|
||||
title=item["title"],
|
||||
thumbnail_url=item.get("thumbnail_url"),
|
||||
duration_seconds=item.get("duration_seconds"),
|
||||
channel_name=ch.get("name", ""),
|
||||
channel_youtube_id=ch.get("youtube_channel_id"),
|
||||
published_at=published_at,
|
||||
is_local=local is not None,
|
||||
is_downloaded=bool(uv and uv.downloaded),
|
||||
is_watched=bool(uv and uv.watched),
|
||||
local_video_id=local.id if local else None,
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
def search(
|
||||
q: str = Query(..., min_length=1),
|
||||
live: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_log_search(db, current_user.id, q)
|
||||
|
||||
# Always run local search first
|
||||
local_videos = _local_video_search(db, current_user.id, q)
|
||||
local_channels = _local_channel_search(db, current_user.id, q)
|
||||
|
||||
video_results = [
|
||||
VideoResult(
|
||||
youtube_video_id=r["youtube_video_id"],
|
||||
title=r["title"],
|
||||
thumbnail_url=r["thumbnail_url"],
|
||||
duration_seconds=r["duration_seconds"],
|
||||
channel_name=r["channel_name"] or "",
|
||||
channel_youtube_id=r["youtube_channel_id"],
|
||||
published_at=r["published_at"],
|
||||
is_local=True,
|
||||
is_downloaded=bool(r["is_downloaded"]),
|
||||
is_watched=bool(r["is_watched"]),
|
||||
local_video_id=r["id"],
|
||||
)
|
||||
for r in local_videos
|
||||
]
|
||||
|
||||
channel_results = [
|
||||
ChannelResult(
|
||||
youtube_channel_id=r["youtube_channel_id"],
|
||||
name=r["name"],
|
||||
thumbnail_url=r["thumbnail_url"],
|
||||
description=r["description"],
|
||||
is_followed=bool(r["is_followed"]),
|
||||
local_channel_id=r["id"],
|
||||
subscriber_count=r.get("subscriber_count"),
|
||||
video_count=r.get("video_count"),
|
||||
)
|
||||
for r in local_channels
|
||||
]
|
||||
|
||||
# Synthesize channel cards from video results for channels not yet in the list
|
||||
found_ch_ids = {c.youtube_channel_id for c in channel_results}
|
||||
|
||||
def _channel_card_from_db(yt_ch_id: str) -> Optional[ChannelResult]:
|
||||
row = db.execute(
|
||||
text("""
|
||||
SELECT c.id, c.youtube_channel_id, c.name, c.thumbnail_url, c.description,
|
||||
c.subscriber_count,
|
||||
CASE WHEN uc.status = 'followed' THEN 1 ELSE 0 END AS is_followed,
|
||||
(SELECT COUNT(*) FROM videos WHERE channel_id = c.id) AS video_count
|
||||
FROM channels c
|
||||
LEFT JOIN user_channels uc ON c.id = uc.channel_id AND uc.user_id = :user_id
|
||||
WHERE c.youtube_channel_id = :yt_ch_id
|
||||
"""),
|
||||
{"user_id": current_user.id, "yt_ch_id": yt_ch_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
return None
|
||||
return ChannelResult(
|
||||
youtube_channel_id=row["youtube_channel_id"],
|
||||
name=row["name"],
|
||||
thumbnail_url=row["thumbnail_url"],
|
||||
description=row["description"],
|
||||
is_followed=bool(row["is_followed"]),
|
||||
local_channel_id=row["id"],
|
||||
subscriber_count=row.get("subscriber_count"),
|
||||
video_count=row.get("video_count"),
|
||||
)
|
||||
|
||||
if video_results:
|
||||
for v in video_results:
|
||||
if not v.channel_youtube_id or v.channel_youtube_id in found_ch_ids:
|
||||
continue
|
||||
found_ch_ids.add(v.channel_youtube_id)
|
||||
card = _channel_card_from_db(v.channel_youtube_id)
|
||||
if card:
|
||||
channel_results.append(card)
|
||||
|
||||
source = "local" if (video_results or channel_results) else "none"
|
||||
|
||||
# Fall back to live yt-dlp search if no local results or explicitly requested
|
||||
if not video_results or live:
|
||||
try:
|
||||
live_raw = ytdlp.search_youtube(q)
|
||||
live_results = _live_search_to_results(db, current_user.id, live_raw)
|
||||
except Exception:
|
||||
live_results = []
|
||||
live_raw = []
|
||||
if live_results:
|
||||
# Merge: deduplicate by youtube_video_id, local results take priority
|
||||
local_ids = {v.youtube_video_id for v in video_results}
|
||||
for r in live_results:
|
||||
if r.youtube_video_id not in local_ids:
|
||||
video_results.append(r)
|
||||
source = "live" if source == "none" else "mixed"
|
||||
|
||||
# Synthesize channel cards from YouTube results for channels not in local DB
|
||||
ch_by_yt_id: dict[str, dict] = {}
|
||||
for item in live_raw:
|
||||
ch = item.get("channel") or {}
|
||||
yt_ch_id = ch.get("youtube_channel_id")
|
||||
if yt_ch_id and yt_ch_id not in found_ch_ids and yt_ch_id not in ch_by_yt_id:
|
||||
ch_by_yt_id[yt_ch_id] = ch
|
||||
for yt_ch_id, ch in ch_by_yt_id.items():
|
||||
card = _channel_card_from_db(yt_ch_id)
|
||||
if card:
|
||||
channel_results.append(card)
|
||||
found_ch_ids.add(yt_ch_id)
|
||||
else:
|
||||
name = (ch.get("name") or "").strip()
|
||||
if name:
|
||||
channel_results.append(ChannelResult(
|
||||
youtube_channel_id=yt_ch_id,
|
||||
name=name,
|
||||
thumbnail_url=None,
|
||||
description=None,
|
||||
is_followed=False,
|
||||
local_channel_id=None,
|
||||
))
|
||||
found_ch_ids.add(yt_ch_id)
|
||||
|
||||
return SearchResponse(
|
||||
videos=video_results,
|
||||
channels=channel_results,
|
||||
source=source,
|
||||
query=q,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def search_history(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the last 8 unique queries for the current user."""
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT query FROM search_history
|
||||
WHERE user_id = :uid
|
||||
GROUP BY query
|
||||
ORDER BY MAX(searched_at) DESC
|
||||
LIMIT 8
|
||||
"""),
|
||||
{"uid": current_user.id},
|
||||
).scalars().all()
|
||||
return {"queries": list(rows)}
|
||||
117
backend/routers/settings.py
Normal file
117
backend/routers/settings.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import User, UserSettings
|
||||
from ..services import ytdlp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
VALID_BROWSERS = {"", "chrome", "chromium", "firefox", "brave", "edge", "opera", "safari"}
|
||||
VALID_REGIONS = {"US", "SE", "GB", "DE", "JP", "FR", "CA", "AU", "BR", "IN", "KR", "MX"}
|
||||
|
||||
|
||||
class SettingsOut(BaseModel):
|
||||
preferred_quality: str
|
||||
max_concurrent_downloads: int
|
||||
hide_watched_from_feed: bool
|
||||
mark_watched_at_percent: int
|
||||
auto_download_on_sync: bool
|
||||
cookies_browser: str = ""
|
||||
theater_mode: bool = False
|
||||
discovery_regions: str = "US,SE"
|
||||
calm_mode: bool = False
|
||||
hide_subscriber_counts: bool = False
|
||||
autoplay_enabled: bool = False
|
||||
feed_weight_recency: float = 5.0
|
||||
feed_weight_affinity: float = 5.0
|
||||
feed_weight_channel: float = 5.0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
preferred_quality: Optional[str] = None
|
||||
max_concurrent_downloads: Optional[int] = Field(None, ge=1, le=5)
|
||||
hide_watched_from_feed: Optional[bool] = None
|
||||
mark_watched_at_percent: Optional[int] = Field(None, ge=50, le=100)
|
||||
auto_download_on_sync: Optional[bool] = None
|
||||
cookies_browser: Optional[str] = None
|
||||
theater_mode: Optional[bool] = None
|
||||
discovery_regions: Optional[str] = None
|
||||
calm_mode: Optional[bool] = None
|
||||
hide_subscriber_counts: Optional[bool] = None
|
||||
autoplay_enabled: Optional[bool] = None
|
||||
feed_weight_recency: Optional[float] = Field(None, ge=0.0, le=10.0)
|
||||
feed_weight_affinity: Optional[float] = Field(None, ge=0.0, le=10.0)
|
||||
feed_weight_channel: Optional[float] = Field(None, ge=0.0, le=10.0)
|
||||
|
||||
|
||||
def _get_or_create(db: Session, user_id: int) -> UserSettings:
|
||||
s = db.query(UserSettings).filter_by(user_id=user_id).first()
|
||||
if not s:
|
||||
s = UserSettings(user_id=user_id)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsOut)
|
||||
def get_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _get_or_create(db, current_user.id)
|
||||
|
||||
|
||||
@router.patch("", response_model=SettingsOut)
|
||||
def update_settings(
|
||||
body: SettingsPatch,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
s = _get_or_create(db, current_user.id)
|
||||
|
||||
if body.preferred_quality is not None and body.preferred_quality in ytdlp.QUALITY_FORMATS:
|
||||
s.preferred_quality = body.preferred_quality
|
||||
if body.max_concurrent_downloads is not None:
|
||||
s.max_concurrent_downloads = body.max_concurrent_downloads
|
||||
ytdlp.set_max_concurrent(body.max_concurrent_downloads)
|
||||
if body.hide_watched_from_feed is not None:
|
||||
s.hide_watched_from_feed = body.hide_watched_from_feed
|
||||
if body.mark_watched_at_percent is not None:
|
||||
s.mark_watched_at_percent = body.mark_watched_at_percent
|
||||
if body.auto_download_on_sync is not None:
|
||||
s.auto_download_on_sync = body.auto_download_on_sync
|
||||
if body.cookies_browser is not None and body.cookies_browser in VALID_BROWSERS:
|
||||
s.cookies_browser = body.cookies_browser
|
||||
ytdlp.set_cookies_browser(body.cookies_browser)
|
||||
if body.theater_mode is not None:
|
||||
s.theater_mode = body.theater_mode
|
||||
if body.discovery_regions is not None:
|
||||
# Validate: comma-separated list of known region codes
|
||||
codes = [r.strip().upper() for r in body.discovery_regions.split(",") if r.strip()]
|
||||
valid = [c for c in codes if c in VALID_REGIONS]
|
||||
if valid:
|
||||
s.discovery_regions = ",".join(valid)
|
||||
if body.calm_mode is not None:
|
||||
s.calm_mode = body.calm_mode
|
||||
if body.hide_subscriber_counts is not None:
|
||||
s.hide_subscriber_counts = body.hide_subscriber_counts
|
||||
if body.autoplay_enabled is not None:
|
||||
s.autoplay_enabled = body.autoplay_enabled
|
||||
if body.feed_weight_recency is not None:
|
||||
s.feed_weight_recency = body.feed_weight_recency
|
||||
if body.feed_weight_affinity is not None:
|
||||
s.feed_weight_affinity = body.feed_weight_affinity
|
||||
if body.feed_weight_channel is not None:
|
||||
s.feed_weight_channel = body.feed_weight_channel
|
||||
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
144
backend/routers/stats.py
Normal file
144
backend/routers/stats.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..auth_utils import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
uid = current_user.id
|
||||
|
||||
totals = db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
COUNT(*) AS total_watched,
|
||||
SUM(uv.watch_progress_seconds) AS total_watch_seconds
|
||||
FROM user_videos uv
|
||||
WHERE uv.user_id = :uid AND uv.watched = 1
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().first()
|
||||
|
||||
top_channels = db.execute(
|
||||
text("""
|
||||
SELECT c.id, c.name,
|
||||
COUNT(*) AS watch_count,
|
||||
SUM(uv.watch_progress_seconds) AS watch_seconds
|
||||
FROM user_videos uv
|
||||
JOIN videos v ON uv.video_id = v.id
|
||||
JOIN channels c ON v.channel_id = c.id
|
||||
WHERE uv.user_id = :uid AND uv.watched = 1
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY watch_seconds DESC
|
||||
LIMIT 10
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
|
||||
daily = db.execute(
|
||||
text("""
|
||||
SELECT date(uv.last_watched_at) AS date,
|
||||
COUNT(*) AS count,
|
||||
SUM(uv.watch_progress_seconds) AS seconds
|
||||
FROM user_videos uv
|
||||
WHERE uv.user_id = :uid
|
||||
AND uv.watched = 1
|
||||
AND uv.last_watched_at >= datetime('now', '-30 days')
|
||||
GROUP BY date(uv.last_watched_at)
|
||||
ORDER BY date ASC
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
|
||||
this_week = db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) AS count, SUM(uv.watch_progress_seconds) AS seconds
|
||||
FROM user_videos uv
|
||||
WHERE uv.user_id = :uid AND uv.watched = 1
|
||||
AND uv.last_watched_at >= datetime('now', '-7 days')
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().first()
|
||||
|
||||
this_month = db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) AS count, SUM(uv.watch_progress_seconds) AS seconds
|
||||
FROM user_videos uv
|
||||
WHERE uv.user_id = :uid AND uv.watched = 1
|
||||
AND uv.last_watched_at >= datetime('now', '-30 days')
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().first()
|
||||
|
||||
avg_completion = db.execute(
|
||||
text("""
|
||||
SELECT AVG(uv.completion_percent) AS avg_pct,
|
||||
COUNT(CASE WHEN uv.completion_percent >= 90 THEN 1 END) AS finished_count,
|
||||
COUNT(CASE WHEN uv.completion_percent < 20 AND uv.completion_percent IS NOT NULL THEN 1 END) AS bailed_count,
|
||||
SUM(uv.rewatch_count) AS total_rewatches,
|
||||
COUNT(CASE WHEN uv.rewatch_count > 0 THEN 1 END) AS rewatched_videos
|
||||
FROM user_videos uv
|
||||
WHERE uv.user_id = :uid AND uv.watched = 1
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().first()
|
||||
|
||||
top_categories = db.execute(
|
||||
text("""
|
||||
SELECT v.category, COUNT(*) AS watch_count,
|
||||
AVG(uv.completion_percent) AS avg_completion
|
||||
FROM user_videos uv
|
||||
JOIN videos v ON uv.video_id = v.id
|
||||
WHERE uv.user_id = :uid AND uv.watched = 1 AND v.category IS NOT NULL
|
||||
GROUP BY v.category
|
||||
ORDER BY watch_count DESC
|
||||
LIMIT 8
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
|
||||
taste_profile = db.execute(
|
||||
text("""
|
||||
SELECT tag, score FROM user_tag_affinity
|
||||
WHERE user_id = :uid AND score > 0
|
||||
ORDER BY score DESC
|
||||
LIMIT 20
|
||||
"""),
|
||||
{"uid": uid},
|
||||
).mappings().all()
|
||||
|
||||
liked_count = db.execute(
|
||||
text("SELECT COUNT(*) AS n FROM user_videos WHERE user_id = :uid AND liked = 1"),
|
||||
{"uid": uid},
|
||||
).mappings().first()
|
||||
|
||||
return {
|
||||
"total_watched": totals["total_watched"] or 0,
|
||||
"total_watch_seconds": totals["total_watch_seconds"] or 0,
|
||||
"top_channels": [dict(r) for r in top_channels],
|
||||
"daily": [dict(r) for r in daily],
|
||||
"this_week": {
|
||||
"count": this_week["count"] or 0,
|
||||
"seconds": this_week["seconds"] or 0,
|
||||
},
|
||||
"this_month": {
|
||||
"count": this_month["count"] or 0,
|
||||
"seconds": this_month["seconds"] or 0,
|
||||
},
|
||||
"avg_completion_percent": round(avg_completion["avg_pct"] or 0, 1),
|
||||
"finished_count": avg_completion["finished_count"] or 0,
|
||||
"bailed_count": avg_completion["bailed_count"] or 0,
|
||||
"total_rewatches": avg_completion["total_rewatches"] or 0,
|
||||
"rewatched_videos": avg_completion["rewatched_videos"] or 0,
|
||||
"total_liked": liked_count["n"] or 0,
|
||||
"top_categories": [dict(r) for r in top_categories],
|
||||
"taste_profile": [dict(r) for r in taste_profile],
|
||||
}
|
||||
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