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:
inputnoise
2026-05-25 20:09:04 +02:00
commit 1827dd6c4e
63 changed files with 14480 additions and 0 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Copy to .env and fill in values
# Required: strong random string for JWT signing
SECRET_KEY=changeme-use-a-real-secret
# Optional: path on the host where downloads are stored
DOWNLOAD_PATH=./downloads
# Optional: disable public registration after you've created your account
ALLOW_REGISTRATION=true
# Optional: Jellyfin integration URL
JELLYFIN_URL=

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Database
app.db
app.db-shm
app.db-wal
data/
# Media
downloads/
# Python
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
# Node
frontend/node_modules/
frontend/dist/
# Env / secrets
.env
.env.*
!.env.example
# User data files
channels.txt
subscriptions.csv
# Editor
.DS_Store
*.swp
*.swo
# Claude Code
.claude/
# Stray DB files
*.db
*.db-shm
*.db-wal

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend/ ./backend/
EXPOSE 8000
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/__init__.py Normal file
View File

49
backend/auth_utils.py Normal file
View File

@@ -0,0 +1,49 @@
from datetime import datetime, timedelta
from typing import Optional
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db
from .models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None:
raise credentials_exception
return user

16
backend/config.py Normal file
View File

@@ -0,0 +1,16 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
database_url: str = "sqlite:///./app.db"
download_path: str = "./downloads"
secret_key: str = "changeme-use-a-real-secret-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 1 week
jellyfin_url: str = ""
allow_registration: bool = True
settings = Settings()

90
backend/database.py Normal file
View File

@@ -0,0 +1,90 @@
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from .config import settings
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False},
echo=False,
)
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, _):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
FTS_SETUP_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS videos_fts USING fts5(
title, description, content=videos, content_rowid=id
);
CREATE VIRTUAL TABLE IF NOT EXISTS channels_fts USING fts5(
name, description, content=channels, content_rowid=id
);
CREATE TRIGGER IF NOT EXISTS videos_ai AFTER INSERT ON videos BEGIN
INSERT INTO videos_fts(rowid, title, description)
VALUES (new.id, new.title, COALESCE(new.description, ''));
END;
CREATE TRIGGER IF NOT EXISTS videos_ad AFTER DELETE ON videos BEGIN
INSERT INTO videos_fts(videos_fts, rowid, title, description)
VALUES ('delete', old.id, old.title, COALESCE(old.description, ''));
END;
CREATE TRIGGER IF NOT EXISTS videos_au AFTER UPDATE ON videos BEGIN
INSERT INTO videos_fts(videos_fts, rowid, title, description)
VALUES ('delete', old.id, old.title, COALESCE(old.description, ''));
INSERT INTO videos_fts(rowid, title, description)
VALUES (new.id, new.title, COALESCE(new.description, ''));
END;
CREATE TRIGGER IF NOT EXISTS channels_ai AFTER INSERT ON channels BEGIN
INSERT INTO channels_fts(rowid, name, description)
VALUES (new.id, new.name, COALESCE(new.description, ''));
END;
CREATE TRIGGER IF NOT EXISTS channels_ad AFTER DELETE ON channels BEGIN
INSERT INTO channels_fts(channels_fts, rowid, name, description)
VALUES ('delete', old.id, old.name, COALESCE(old.description, ''));
END;
CREATE TRIGGER IF NOT EXISTS channels_au AFTER UPDATE ON channels BEGIN
INSERT INTO channels_fts(channels_fts, rowid, name, description)
VALUES ('delete', old.id, old.name, COALESCE(old.description, ''));
INSERT INTO channels_fts(rowid, name, description)
VALUES (new.id, new.name, COALESCE(new.description, ''));
END;
"""
def init_db():
from . import models # noqa: F401
Base.metadata.create_all(bind=engine)
# executescript handles multi-statement SQL including trigger BEGIN...END blocks
raw_conn = engine.raw_connection()
try:
raw_conn.executescript(FTS_SETUP_SQL)
finally:
raw_conn.close()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

159
backend/main.py Normal file
View File

@@ -0,0 +1,159 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .config import settings
from .database import init_db, SessionLocal
from .services import ytdlp as ytdlp_service
from .routers import auth, channels, videos, search, downloads, discovery, settings as settings_router, stats as stats_router, export as export_router, collections as collections_router, admin as admin_router
app = FastAPI(title="YouTube Hub", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(channels.router, prefix="/api/channels", tags=["channels"])
app.include_router(videos.router, prefix="/api/videos", tags=["videos"])
app.include_router(search.router, prefix="/api/search", tags=["search"])
app.include_router(downloads.router, prefix="/api/downloads", tags=["downloads"])
app.include_router(discovery.router, prefix="/api/discovery", tags=["discovery"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(stats_router.router, prefix="/api/stats", tags=["stats"])
app.include_router(export_router.router, prefix="/api/export", tags=["export"])
app.include_router(collections_router.router, prefix="/api/collections", tags=["collections"])
app.include_router(admin_router.router, prefix="/api/admin", tags=["admin"])
os.makedirs(settings.download_path, exist_ok=True)
app.mount("/files", StaticFiles(directory=settings.download_path), name="files")
@app.on_event("startup")
def on_startup():
from sqlalchemy import text
init_db()
db = SessionLocal()
for col_sql in [
"ALTER TABLE user_videos ADD COLUMN liked BOOLEAN DEFAULT FALSE",
"ALTER TABLE user_videos ADD COLUMN liked_at DATETIME",
"ALTER TABLE downloads ADD COLUMN resolution TEXT",
"ALTER TABLE user_channels ADD COLUMN auto_download BOOLEAN DEFAULT NULL",
"ALTER TABLE user_channels ADD COLUMN last_seen_at DATETIME",
"ALTER TABLE discovery_queue ADD COLUMN preview_json TEXT",
"ALTER TABLE channels ADD COLUMN subscriber_count INTEGER",
"ALTER TABLE user_settings ADD COLUMN cookies_browser TEXT DEFAULT ''",
"ALTER TABLE user_settings ADD COLUMN theater_mode INTEGER DEFAULT 0",
"ALTER TABLE user_channels ADD COLUMN muted_until DATETIME DEFAULT NULL",
"ALTER TABLE user_settings ADD COLUMN calm_mode INTEGER DEFAULT 0",
"ALTER TABLE user_settings ADD COLUMN hide_subscriber_counts INTEGER DEFAULT 0",
"ALTER TABLE user_settings ADD COLUMN autoplay_enabled INTEGER DEFAULT 0",
"ALTER TABLE user_videos ADD COLUMN rating INTEGER DEFAULT NULL",
"ALTER TABLE downloads ADD COLUMN pending_delete_at DATETIME DEFAULT NULL",
"ALTER TABLE user_channels ADD COLUMN notes TEXT DEFAULT ''",
"ALTER TABLE videos ADD COLUMN chapters TEXT DEFAULT NULL",
"ALTER TABLE video_bookmarks ADD COLUMN source TEXT DEFAULT 'manual'",
"ALTER TABLE user_videos ADD COLUMN completion_percent REAL DEFAULT NULL",
"ALTER TABLE user_videos ADD COLUMN rewatch_count INTEGER DEFAULT 0",
"ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0",
"""CREATE TABLE IF NOT EXISTS system_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)""",
"ALTER TABLE user_settings ADD COLUMN feed_weight_recency REAL DEFAULT 5.0",
"ALTER TABLE user_settings ADD COLUMN feed_weight_affinity REAL DEFAULT 5.0",
"ALTER TABLE user_settings ADD COLUMN feed_weight_channel REAL DEFAULT 5.0",
"""CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
query TEXT NOT NULL,
searched_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS user_tag_affinity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
score REAL DEFAULT 0.0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, tag)
)""",
"""CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS collection_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, video_id)
)""",
"""CREATE TABLE IF NOT EXISTS video_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
timestamp_seconds INTEGER NOT NULL,
note TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
]:
try:
db.execute(text(col_sql))
db.commit()
except Exception:
db.rollback()
try:
# Rebuild FTS indexes so all existing rows are searchable
db.execute(text("INSERT INTO videos_fts(videos_fts) VALUES('rebuild')"))
db.execute(text("INSERT INTO channels_fts(channels_fts) VALUES('rebuild')"))
# Migrate signed/expiring YouTube thumbnail URLs to stable format
db.execute(text("""
UPDATE videos
SET thumbnail_url = 'https://i.ytimg.com/vi/' || youtube_video_id || '/hqdefault.jpg'
WHERE thumbnail_url IS NULL
OR thumbnail_url NOT LIKE 'https://i.ytimg.com/vi/%/hqdefault.jpg'
"""))
db.commit()
# On a fresh install with no admin yet, promote the first registered user
from .models import User as UserModel, SystemConfig
has_admin = db.query(UserModel).filter_by(is_admin=True).first()
if not has_admin:
first_user = db.query(UserModel).order_by(UserModel.id).first()
if first_user:
first_user.is_admin = True
db.commit()
# Seed system_config from env if not already set
if not db.query(SystemConfig).filter_by(key="allow_registration").first():
db.add(SystemConfig(
key="allow_registration",
value="true" if settings.allow_registration else "false",
))
db.commit()
# Apply user's saved concurrent download limit on startup
from .models import UserSettings
first_user_settings = db.query(UserSettings).first()
if first_user_settings:
ytdlp_service.set_max_concurrent(first_user_settings.max_concurrent_downloads)
ytdlp_service.set_cookies_browser(first_user_settings.cookies_browser or "")
finally:
db.close()
# Backfill descriptions for videos that don't have them yet (runs in background)
import threading
from .routers.channels import _enrich_missing_task
threading.Thread(target=_enrich_missing_task, args=(10,), daemon=True).start()
@app.get("/api/health")
def health():
return {"status": "ok"}

221
backend/models.py Normal file
View File

@@ -0,0 +1,221 @@
from datetime import datetime
from sqlalchemy import (
Boolean, Column, DateTime, Float, ForeignKey,
Integer, String, Text, UniqueConstraint,
)
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, nullable=False, index=True)
email = Column(String, unique=True, nullable=False, index=True)
hashed_password = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
class Channel(Base):
__tablename__ = "channels"
id = Column(Integer, primary_key=True, index=True)
youtube_channel_id = Column(String, unique=True, nullable=False, index=True)
name = Column(String, nullable=False)
description = Column(Text)
thumbnail_url = Column(String)
banner_url = Column(String)
crawled_at = Column(DateTime)
subscriber_count = Column(Integer)
class UserChannel(Base):
__tablename__ = "user_channels"
__table_args__ = (UniqueConstraint("user_id", "channel_id"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
status = Column(String, default="followed") # followed / dismissed / pending
added_at = Column(DateTime, default=datetime.utcnow)
auto_download = Column(Boolean, default=None) # None = use global, True/False = override
last_seen_at = Column(DateTime, default=None)
muted_until = Column(DateTime, default=None)
notes = Column(Text, default="")
class Video(Base):
__tablename__ = "videos"
id = Column(Integer, primary_key=True, index=True)
youtube_video_id = Column(String, unique=True, nullable=False, index=True)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="SET NULL"), nullable=True)
title = Column(String, nullable=False)
description = Column(Text)
thumbnail_url = Column(String)
duration_seconds = Column(Integer)
published_at = Column(DateTime)
indexed_at = Column(DateTime, default=datetime.utcnow)
tags = Column(Text) # JSON array string
category = Column(String)
chapters = Column(Text) # JSON array of {start_time, end_time, title}
class UserVideo(Base):
__tablename__ = "user_videos"
__table_args__ = (UniqueConstraint("user_id", "video_id"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
watched = Column(Boolean, default=False)
watch_progress_seconds = Column(Integer, default=0)
completion_percent = Column(Float, default=None) # 0100, set when video ends/navigates away
rewatch_count = Column(Integer, default=0) # incremented each time a completed video is replayed
queued = Column(Boolean, default=False)
downloaded = Column(Boolean, default=False)
liked = Column(Boolean, default=False)
rating = Column(Integer, default=None) # NULL=unrated, 1=thumbs up, -1=thumbs down
downloaded_at = Column(DateTime)
liked_at = Column(DateTime)
last_watched_at = Column(DateTime)
class Download(Base):
__tablename__ = "downloads"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
status = Column(String, default="pending") # pending / downloading / complete / failed
progress_percent = Column(Float, default=0.0)
file_path = Column(String)
resolution = Column(String) # e.g. "1080p", "720p"
created_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime)
error_message = Column(Text)
pending_delete_at = Column(DateTime, default=None)
class UserSettings(Base):
__tablename__ = "user_settings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True)
preferred_quality = Column(String, default="best") # best / 1080p / 720p / 480p / 360p
max_concurrent_downloads = Column(Integer, default=3) # 15
hide_watched_from_feed = Column(Boolean, default=False)
mark_watched_at_percent = Column(Integer, default=90) # 50100
auto_download_on_sync = Column(Boolean, default=False)
cookies_browser = Column(String, default="") # chrome / firefox / etc., "" = disabled
theater_mode = Column(Boolean, default=False)
discovery_regions = Column(String, default="US,SE") # comma-separated ISO country codes
calm_mode = Column(Boolean, default=False)
hide_subscriber_counts = Column(Boolean, default=False)
autoplay_enabled = Column(Boolean, default=False)
feed_weight_recency = Column(Float, default=5.0) # 010
feed_weight_affinity = Column(Float, default=5.0) # 010
feed_weight_channel = Column(Float, default=5.0) # 010
class DiscoveryQueue(Base):
__tablename__ = "discovery_queue"
__table_args__ = (UniqueConstraint("user_id", "channel_id"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
score = Column(Float, default=0.0)
source = Column(String) # search / community / category / liked
seen = Column(Boolean, default=False)
preview_json = Column(Text) # JSON: [{thumbnail_url, title}, ...]
created_at = Column(DateTime, default=datetime.utcnow)
class ChannelGroup(Base):
__tablename__ = "channel_groups"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
class ChannelGroupMember(Base):
__tablename__ = "channel_group_members"
__table_args__ = (UniqueConstraint("group_id", "channel_id"),)
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("channel_groups.id", ondelete="CASCADE"), nullable=False)
channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
class UserTagAffinity(Base):
"""Per-user taste signal: how much the user engages with a given tag or category."""
__tablename__ = "user_tag_affinity"
__table_args__ = (UniqueConstraint("user_id", "tag"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
tag = Column(String, nullable=False, index=True)
score = Column(Float, default=0.0)
updated_at = Column(DateTime, default=datetime.utcnow)
class Collection(Base):
__tablename__ = "collections"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
class CollectionItem(Base):
__tablename__ = "collection_items"
__table_args__ = (UniqueConstraint("collection_id", "video_id"),)
id = Column(Integer, primary_key=True, index=True)
collection_id = Column(Integer, ForeignKey("collections.id", ondelete="CASCADE"), nullable=False)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
added_at = Column(DateTime, default=datetime.utcnow)
class GraphEdge(Base):
__tablename__ = "graph_edges"
__table_args__ = (UniqueConstraint("from_channel_id", "to_channel_id"),)
id = Column(Integer, primary_key=True, index=True)
from_channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
to_channel_id = Column(Integer, ForeignKey("channels.id", ondelete="CASCADE"), nullable=False)
mention_count = Column(Integer, default=1)
last_seen = Column(DateTime, default=datetime.utcnow)
class SystemConfig(Base):
__tablename__ = "system_config"
key = Column(String, primary_key=True)
value = Column(String, nullable=False)
class SearchHistory(Base):
__tablename__ = "search_history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
query = Column(String, nullable=False)
searched_at = Column(DateTime, default=datetime.utcnow)
class VideoBookmark(Base):
__tablename__ = "video_bookmarks"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
timestamp_seconds = Column(Integer, nullable=False)
note = Column(Text, default="")
source = Column(String, default="manual") # manual | auto
created_at = Column(DateTime, default=datetime.utcnow)

10
backend/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
python-jose[cryptography]==3.3.0
bcrypt==5.0.0
python-multipart==0.0.12
pydantic-settings==2.6.1
yt-dlp>=2024.11.18
httpx==0.27.2
aiofiles==24.1.0

View File

86
backend/routers/admin.py Normal file
View 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
View 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
View 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}

View 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()

View 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]

View 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
View 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
View 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
View 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
View 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
View 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}

View File

View File

@@ -0,0 +1,614 @@
"""Discovery engine — search-based crawl, trending, community signal, category clustering."""
import json
import random
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..models import Channel, UserChannel, DiscoveryQueue, Video
from . import ytdlp
def _fetch_and_index_channel(db: Session, channel: Channel):
"""Fetch full metadata + recent videos for a discovered channel."""
try:
result = ytdlp.fetch_channel_metadata(channel.youtube_channel_id, max_videos=10)
if not result:
return
ch_data = result.get("channel", {})
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()
videos = result.get("videos", [])
# For videos missing a date (RSS didn't cover them or flat-playlist had no timestamp),
# do individual fetches — capped at 3 to avoid slow-downs.
dateless = [v for v in videos if not v.get("published_at")]
individual_fetched: dict[str, dict] = {}
for vdata in dateless[:3]:
yt_id = vdata.get("youtube_video_id")
if not yt_id:
continue
try:
meta = ytdlp.fetch_video_metadata(yt_id)
if meta and meta.get("published_at"):
individual_fetched[yt_id] = meta
except Exception:
pass
for vdata in videos:
yt_id = vdata.get("youtube_video_id")
if not yt_id:
continue
# Prefer individually-fetched metadata if we retrieved it
if yt_id in individual_fetched:
vdata = individual_fetched[yt_id]
# Skip videos we still can't date — undated videos break feed ordering
if not vdata.get("published_at"):
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()
except Exception:
db.rollback()
def _upsert_channel(db: Session, channel_data: dict) -> Channel | None:
yt_id = channel_data.get("youtube_channel_id")
if not yt_id:
return None
channel = db.query(Channel).filter_by(youtube_channel_id=yt_id).first()
if not channel:
channel = Channel(**channel_data)
db.add(channel)
db.flush()
return channel
def _add_to_discovery(
db: Session, user_id: int, channel_id: int, score: float, source: str,
preview_json: str | None = None,
):
existing = db.query(DiscoveryQueue).filter_by(user_id=user_id, channel_id=channel_id).first()
if existing:
# Accumulate scores across sources but cap to prevent one dominant signal
existing.score = existing.score + score * 0.5
if preview_json and not existing.preview_json:
existing.preview_json = preview_json
return
db.add(DiscoveryQueue(
user_id=user_id,
channel_id=channel_id,
score=score,
source=source,
preview_json=preview_json,
))
def _search_and_store(
db: Session, user_id: int, queries: list[str],
followed_yt_ids: set[str], score_multiplier: float, source: str,
):
"""Run YouTube searches for the given queries and add results to discovery."""
discovered: dict[str, dict] = {}
for query in queries:
try:
results = ytdlp.search_youtube(query, max_results=20)
for video in results:
ch = video.get("channel", {})
yt_id = ch.get("youtube_channel_id")
name = (ch.get("name") or "").strip()
if yt_id and name and yt_id not in followed_yt_ids:
if yt_id not in discovered:
discovered[yt_id] = {"name": name, "count": 0, "previews": []}
discovered[yt_id]["count"] += 1
previews = discovered[yt_id]["previews"]
if len(previews) < 3 and video.get("thumbnail_url") and video.get("title"):
previews.append({
"thumbnail_url": video["thumbnail_url"],
"title": video["title"],
})
except Exception:
continue
if not discovered:
return
candidates = sorted(discovered.items(), key=lambda x: -x[1]["count"])
needs_indexing: list[int] = []
for yt_id, info in candidates:
channel = db.query(Channel).filter_by(youtube_channel_id=yt_id).first()
is_new = channel is None
if not channel:
channel = Channel(
youtube_channel_id=yt_id,
name=info["name"],
description="",
thumbnail_url=None,
)
db.add(channel)
db.flush()
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
if uc and uc.status in ("followed", "dismissed"):
continue
preview_json = json.dumps(info["previews"]) if info["previews"] else None
_add_to_discovery(
db, user_id, channel.id,
score=float(info["count"]) * score_multiplier,
source=source,
preview_json=preview_json,
)
if is_new or not channel.crawled_at:
needs_indexing.append(channel.id)
db.commit()
for channel_id in needs_indexing[:5]:
channel = db.query(Channel).filter_by(id=channel_id).first()
if channel:
_fetch_and_index_channel(db, channel)
def crawl_by_search(db: Session, user_id: int):
"""Discover channels by searching YouTube using tags, categories, and channel names."""
# All followed channels (names + yt_ids)
followed_rows = db.execute(
text("""
SELECT c.name, c.youtube_channel_id
FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
"""),
{"user_id": user_id},
).mappings().all()
followed_yt_ids = {row["youtube_channel_id"] for row in followed_rows}
followed_names = [row["name"] for row in followed_rows if row["name"]]
# Top tags from followed channels' indexed videos + liked videos
# SQLite requires LIMIT inside a subquery when used with UNION ALL
tag_rows = db.execute(
text("""
SELECT tags FROM (
SELECT v.tags
FROM videos v
JOIN user_channels uc ON v.channel_id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
LIMIT 300
)
UNION ALL
SELECT tags FROM (
SELECT v.tags
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :user_id AND uv.liked = 1
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
LIMIT 100
)
"""),
{"user_id": user_id},
).mappings().all()
tag_counts: dict[str, int] = {}
for row in tag_rows:
try:
tags = json.loads(row["tags"])
for tag in tags:
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40:
tag_counts[t] = tag_counts.get(t, 0) + 1
except (json.JSONDecodeError, TypeError):
continue
# Top categories as fallback
cat_rows = db.execute(
text("""
SELECT v.category, COUNT(*) AS cnt
FROM videos v
JOIN user_channels uc ON v.channel_id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
AND v.category IS NOT NULL
GROUP BY v.category
ORDER BY cnt DESC
LIMIT 5
"""),
{"user_id": user_id},
).mappings().all()
# Build query pool: top tags + random channel names + categories
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:6]]
top_cats = [r["category"] for r in cat_rows]
# Random sample of followed channel names — diversifies discovery each run
sampled_names: list[str] = []
if followed_names:
sampled_names = random.sample(followed_names, min(8, len(followed_names)))
# Combine: tags (most signal) + channel names (broad reach) + categories (fallback)
queries = list(dict.fromkeys(top_tags + sampled_names + top_cats))[:15]
if not queries:
return
_search_and_store(db, user_id, queries, followed_yt_ids, score_multiplier=5.0, source="search")
def update_community_signal(db: Session, user_id: int):
"""Surface channels that other users follow, weighted by follower count."""
rows = db.execute(
text("""
SELECT uc.channel_id, COUNT(DISTINCT uc.user_id) AS follower_count
FROM user_channels uc
WHERE uc.user_id != :user_id
AND uc.status = 'followed'
AND uc.channel_id NOT IN (
SELECT channel_id FROM user_channels
WHERE user_id = :user_id
)
GROUP BY uc.channel_id
ORDER BY follower_count DESC
LIMIT 100
"""),
{"user_id": user_id},
).mappings().all()
for row in rows:
_add_to_discovery(
db, user_id, row["channel_id"],
score=float(row["follower_count"]) * 5,
source="community",
)
db.commit()
def update_category_clusters(db: Session, user_id: int):
"""Find channels in categories the user watches heavily."""
rows = db.execute(
text("""
SELECT v.category, COUNT(*) AS watch_count
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :user_id AND uv.watched = 1 AND v.category IS NOT NULL
GROUP BY v.category
ORDER BY watch_count DESC
LIMIT 5
"""),
{"user_id": user_id},
).mappings().all()
top_categories = [r["category"] for r in rows]
if not top_categories:
return
placeholders = ",".join(f"'{c}'" for c in top_categories)
candidate_rows = db.execute(
text(f"""
SELECT DISTINCT v.channel_id
FROM videos v
WHERE v.category IN ({placeholders})
AND v.channel_id NOT IN (
SELECT channel_id FROM user_channels WHERE user_id = :user_id
)
LIMIT 100
"""),
{"user_id": user_id},
).mappings().all()
for row in candidate_rows:
_add_to_discovery(db, user_id, row["channel_id"], score=3.0, source="category")
db.commit()
def update_liked_signal(db: Session, user_id: int):
"""Search YouTube for channels related to topics extracted from liked videos."""
liked_rows = db.execute(
text("""
SELECT v.tags
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :user_id AND uv.liked = 1
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
"""),
{"user_id": user_id},
).mappings().all()
if not liked_rows:
return
tag_counts: dict[str, int] = {}
for row in liked_rows:
try:
tags = json.loads(row["tags"])
for tag in tags:
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40:
tag_counts[t] = tag_counts.get(t, 0) + 2
except (json.JSONDecodeError, TypeError):
pass
if not tag_counts:
return
followed_yt_ids = set(db.execute(
text("""
SELECT c.youtube_channel_id FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
"""),
{"user_id": user_id},
).scalars().all())
top_tags = [t for t, _ in sorted(tag_counts.items(), key=lambda x: -x[1])[:6]]
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=10.0, source="liked")
def update_watch_signal(db: Session, user_id: int):
"""Discover channels from watched video topics, dampened so a single view has little effect.
A tag needs to appear in at least 3 distinct watched videos before it influences
discovery. Each qualifying tag contributes a modest score (×3 vs liked ×10),
so watching a single Tokyo video won't flood recommendations with Tokyo content.
"""
rows = db.execute(
text("""
SELECT v.tags
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :user_id AND uv.watched = 1
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
"""),
{"user_id": user_id},
).mappings().all()
if not rows:
return
tag_counts: dict[str, int] = {}
for row in rows:
try:
tags = json.loads(row["tags"])
seen = set()
for tag in tags:
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40 and t not in seen:
tag_counts[t] = tag_counts.get(t, 0) + 1
seen.add(t)
except (json.JSONDecodeError, TypeError):
pass
# Only use tags that appear across 3+ distinct watched videos
qualified = {t: c for t, c in tag_counts.items() if c >= 3}
if not qualified:
return
followed_yt_ids = set(db.execute(
text("""
SELECT c.youtube_channel_id FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
"""),
{"user_id": user_id},
).scalars().all())
top_tags = [t for t, _ in sorted(qualified.items(), key=lambda x: -x[1])[:6]]
_search_and_store(db, user_id, top_tags, followed_yt_ids, score_multiplier=3.0, source="watched")
def _build_user_tag_profile(db: Session, user_id: int) -> dict[str, float]:
"""Return a weighted tag dict from liked (weight 3) + watched (weight 1) videos."""
rows = db.execute(
text("""
SELECT v.tags, MAX(uv.liked) AS liked
FROM user_videos uv
JOIN videos v ON uv.video_id = v.id
WHERE uv.user_id = :user_id AND (uv.liked = 1 OR uv.watched = 1)
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
GROUP BY v.id
"""),
{"user_id": user_id},
).mappings().all()
profile: dict[str, float] = {}
for row in rows:
weight = 3.0 if row["liked"] else 1.0
try:
for tag in json.loads(row["tags"]):
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40:
profile[t] = profile.get(t, 0.0) + weight
except (json.JSONDecodeError, TypeError):
pass
return profile
def _tag_relevance_score(tag_profile: dict[str, float], tags_json: str | None) -> float:
"""Score a candidate channel's tags against the user's interest profile."""
if not tag_profile or not tags_json:
return 0.0
try:
tags = json.loads(tags_json)
except (json.JSONDecodeError, TypeError):
return 0.0
score = 0.0
for tag in tags:
if isinstance(tag, str):
t = tag.lower().strip()
score += tag_profile.get(t, 0.0)
return min(score, 50.0)
def _dismissed_channel_tags(db: Session, user_id: int) -> set[str]:
"""Collect tags of channels this user explicitly dismissed — used to avoid similar content."""
rows = db.execute(
text("""
SELECT v.tags
FROM user_channels uc
JOIN videos v ON v.channel_id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'dismissed'
AND v.tags IS NOT NULL AND v.tags != '' AND v.tags != '[]'
LIMIT 500
"""),
{"user_id": user_id},
).mappings().all()
bad_tags: dict[str, int] = {}
for row in rows:
try:
for tag in json.loads(row["tags"]):
if isinstance(tag, str):
t = tag.lower().strip()
if 3 <= len(t) <= 40:
bad_tags[t] = bad_tags.get(t, 0) + 1
except (json.JSONDecodeError, TypeError):
pass
# Only include tags that appeared in 3+ dismissed-channel videos (strong signal)
return {t for t, c in bad_tags.items() if c >= 3}
def update_trending_signal(db: Session, user_id: int, regions: list[str]):
"""Fetch trending videos per region and score them by tag overlap with user interests."""
if not regions:
return
tag_profile = _build_user_tag_profile(db, user_id)
dismiss_tags = _dismissed_channel_tags(db, user_id)
followed_yt_ids = set(db.execute(
text("""
SELECT c.youtube_channel_id FROM channels c
JOIN user_channels uc ON c.id = uc.channel_id
WHERE uc.user_id = :user_id AND uc.status = 'followed'
"""),
{"user_id": user_id},
).scalars().all())
dismissed_channel_ids = set(db.execute(
text("""
SELECT channel_id FROM user_channels
WHERE user_id = :user_id AND status = 'dismissed'
"""),
{"user_id": user_id},
).scalars().all())
discovered: dict[str, dict] = {}
for region in regions:
try:
videos = ytdlp.fetch_trending(region=region, max_results=50)
for video in videos:
ch = video.get("channel", {})
yt_id = ch.get("youtube_channel_id")
name = (ch.get("name") or "").strip()
if not yt_id or not name or yt_id in followed_yt_ids:
continue
if yt_id not in discovered:
discovered[yt_id] = {"name": name, "count": 0, "regions": set(), "previews": []}
discovered[yt_id]["count"] += 1
discovered[yt_id]["regions"].add(region)
previews = discovered[yt_id]["previews"]
if len(previews) < 3 and video.get("thumbnail_url") and video.get("title"):
previews.append({
"thumbnail_url": video["thumbnail_url"],
"title": video["title"],
})
except Exception:
continue
if not discovered:
return
needs_indexing: list[int] = []
for yt_id, info in discovered.items():
channel = db.query(Channel).filter_by(youtube_channel_id=yt_id).first()
is_new = channel is None
if not channel:
channel = Channel(
youtube_channel_id=yt_id,
name=info["name"],
description="",
thumbnail_url=None,
)
db.add(channel)
db.flush()
if channel.id in dismissed_channel_ids:
continue
uc = db.query(UserChannel).filter_by(user_id=user_id, channel_id=channel.id).first()
if uc and uc.status in ("followed", "dismissed"):
continue
# Score: base ×4 per region × count, boosted by tag relevance, penalised by dismiss-tag overlap
base_score = float(info["count"]) * 4.0 * len(info["regions"])
# Tag relevance boost (requires channel to have indexed videos)
tag_boost = 0.0
if not is_new and channel.crawled_at:
tag_rows = db.execute(
text("SELECT tags FROM videos WHERE channel_id = :cid AND tags IS NOT NULL LIMIT 20"),
{"cid": channel.id},
).scalars().all()
for tags_json in tag_rows:
tag_boost += _tag_relevance_score(tag_profile, tags_json)
tag_boost = min(tag_boost, 30.0)
# Dismiss penalty: if channel's tags overlap heavily with dismissed content, reduce score
dismiss_penalty = 0.0
if dismiss_tags and not is_new:
tag_rows2 = db.execute(
text("SELECT tags FROM videos WHERE channel_id = :cid AND tags IS NOT NULL LIMIT 20"),
{"cid": channel.id},
).scalars().all()
for tags_json in tag_rows2:
try:
for tag in json.loads(tags_json or "[]"):
if isinstance(tag, str) and tag.lower().strip() in dismiss_tags:
dismiss_penalty += 5.0
except (json.JSONDecodeError, TypeError):
pass
dismiss_penalty = min(dismiss_penalty, base_score * 0.8)
final_score = base_score + tag_boost - dismiss_penalty
if final_score <= 0:
continue
preview_json = json.dumps(info["previews"]) if info["previews"] else None
_add_to_discovery(db, user_id, channel.id, score=final_score, source="trending", preview_json=preview_json)
if is_new or not channel.crawled_at:
needs_indexing.append(channel.id)
db.commit()
for channel_id in needs_indexing[:5]:
channel = db.query(Channel).filter_by(id=channel_id).first()
if channel:
_fetch_and_index_channel(db, channel)
def run_full_discovery(db: Session, user_id: int, regions: list[str] | None = None):
if regions is None:
regions = ["US", "SE"]
crawl_by_search(db, user_id)
update_community_signal(db, user_id)
update_category_clusters(db, user_id)
update_liked_signal(db, user_id)
update_watch_signal(db, user_id)
update_trending_signal(db, user_id, regions)

View File

@@ -0,0 +1,86 @@
"""Surprise Me scoring logic."""
import random
from datetime import datetime, time
from sqlalchemy.orm import Session
from sqlalchemy import text
SURPRISE_SQL = """
WITH candidate_scores AS (
SELECT
v.id AS video_id,
v.youtube_video_id,
v.title,
v.thumbnail_url,
v.duration_seconds,
v.channel_id,
c.name AS channel_name,
c.thumbnail_url AS channel_thumbnail_url,
uv.watched,
uv.watch_progress_seconds,
uv.downloaded,
uv.last_watched_at,
-- Unplayed download bonus
CASE WHEN uv.downloaded = 1 AND (uv.watched IS NULL OR uv.watched = 0) THEN 40 ELSE 0 END
-- Recency penalty
+ CASE
WHEN uv.last_watched_at IS NOT NULL
AND uv.last_watched_at > datetime('now', '-7 days') THEN -50
WHEN uv.last_watched_at IS NOT NULL
AND uv.last_watched_at > datetime('now', '-30 days') THEN -20
ELSE 0
END
-- Late evening duration bonus (applied in Python)
+ :duration_bonus_active * CASE WHEN v.duration_seconds > 2700 THEN 10 ELSE 0 END
-- Random jitter
+ (ABS(RANDOM()) % 11 - 5) AS base_score
FROM videos v
JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
JOIN channels c ON v.channel_id = c.id
WHERE uv.downloaded = 1
)
SELECT * FROM candidate_scores
ORDER BY base_score DESC
LIMIT 50
"""
def get_surprise_videos(db: Session, user_id: int, limit: int = 10) -> list[dict]:
now = datetime.now()
late_evening = now.time() >= time(21, 0)
rows = db.execute(
text(SURPRISE_SQL),
{"user_id": user_id, "duration_bonus_active": 1 if late_evening else 0},
).mappings().all()
# Apply channel diversity penalty in Python
seen_channels: dict[int, int] = {}
results = []
for row in rows:
row = dict(row)
channel_id = row["channel_id"]
penalty = seen_channels.get(channel_id, 0) * 30
row["final_score"] = row["base_score"] - penalty
seen_channels[channel_id] = seen_channels.get(channel_id, 0) + 1
results.append(row)
results.sort(key=lambda r: r["final_score"], reverse=True)
return results[:limit]
def get_discovery_injection(db: Session, user_id: int) -> dict | None:
"""Return one unseen discovery queue item to inject into Surprise Me."""
row = db.execute(
text("""
SELECT dq.id, c.id AS channel_id, c.name, c.thumbnail_url,
dq.source, dq.score
FROM discovery_queue dq
JOIN channels c ON dq.channel_id = c.id
WHERE dq.user_id = :user_id AND dq.seen = 0
ORDER BY dq.score DESC
LIMIT 1
"""),
{"user_id": user_id},
).mappings().first()
return dict(row) if row else None

486
backend/services/ytdlp.py Normal file
View File

@@ -0,0 +1,486 @@
"""Subprocess wrapper for yt-dlp."""
import json
import re
import subprocess
import threading
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from ..config import settings
def _run(args: list[str], timeout: int = 60) -> tuple[str, str, int]:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return result.stdout, result.stderr, result.returncode
def _parse_date(date_str: str | None) -> datetime | None:
if not date_str:
return None
try:
return datetime.strptime(date_str, "%Y%m%d")
except ValueError:
return None
def _parse_published(info: dict) -> datetime | None:
"""Extract publish date from yt-dlp info dict.
Tries upload_date (YYYYMMDD string) first, then timestamp (Unix epoch),
then release_timestamp. Flat-playlist entries often omit upload_date but
include timestamp, so the fallback is important.
"""
d = _parse_date(info.get("upload_date"))
if d:
return d
for key in ("timestamp", "release_timestamp"):
ts = info.get(key)
if ts:
try:
return datetime.utcfromtimestamp(float(ts))
except (ValueError, OSError, OverflowError):
pass
return None
def _stable_thumbnail(video_id: str | None) -> str | None:
if not video_id:
return None
return f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
def _normalize_video(info: dict) -> dict:
video_id = info.get("id")
raw_chapters = info.get("chapters") or []
chapters = [
{
"start_time": int(ch.get("start_time") or 0),
"end_time": int(ch.get("end_time") or 0),
"title": ch.get("title") or "",
}
for ch in raw_chapters
if ch.get("title")
]
return {
"youtube_video_id": video_id,
"title": info.get("title", ""),
"description": info.get("description", ""),
"thumbnail_url": _stable_thumbnail(video_id),
"duration_seconds": info.get("duration"),
"published_at": _parse_published(info),
"tags": json.dumps(info.get("tags") or []),
"category": info.get("category") or (info.get("categories") or [None])[0],
"chapters": json.dumps(chapters) if chapters else None,
"channel": {
"youtube_channel_id": info.get("channel_id"),
"name": info.get("channel") or info.get("uploader", ""),
"thumbnail_url": None,
},
}
def _channel_avatar(thumbnails: list | None) -> str | None:
"""Pick the channel avatar from yt-dlp's thumbnails list.
YouTube returns banners and avatars in the same array. Avatars have id
'avatar_uncropped' or are roughly square (width ≈ height).
"""
if not thumbnails:
return None
for t in thumbnails:
if "avatar" in str(t.get("id") or "").lower():
return t.get("url")
# Fall back to the most square thumbnail
square = [t for t in thumbnails
if t.get("width") and t.get("height")
and t["width"] <= t["height"] * 1.2
and t["height"] <= t["width"] * 1.2]
if square:
return max(square, key=lambda t: t.get("width") or 0).get("url")
return None
def _normalize_channel(info: dict) -> dict:
return {
"youtube_channel_id": info.get("channel_id") or info.get("id"),
"name": info.get("channel") or info.get("title") or info.get("uploader") or None,
"description": info.get("description") or None,
"thumbnail_url": _channel_avatar(info.get("thumbnails")),
"banner_url": None,
"subscriber_count": info.get("channel_follower_count"),
}
def search_youtube(query: str, max_results: int = 40) -> list[dict]:
"""Search YouTube via yt-dlp. Uses --flat-playlist for fast results."""
stdout, _, code = _run([
"yt-dlp",
f"ytsearch{max_results}:{query}",
"--dump-json",
"--flat-playlist",
"--quiet",
*_cookie_args(),
], timeout=60)
results = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
# flat-playlist entries have _type="url" with basic fields
if info.get("_type") in ("url", None) and info.get("id"):
results.append({
"youtube_video_id": info.get("id"),
"title": info.get("title", ""),
"description": info.get("description") or "",
"thumbnail_url": _stable_thumbnail(info.get("id")),
"duration_seconds": info.get("duration"),
"published_at": _parse_published(info),
"tags": json.dumps(info.get("tags") or []),
"category": None,
"channel": {
"youtube_channel_id": info.get("channel_id"),
"name": info.get("channel") or info.get("uploader") or "",
"thumbnail_url": None,
},
})
except json.JSONDecodeError:
continue
return results
def fetch_trending(region: str = "US", max_results: int = 50) -> list[dict]:
"""Fetch trending videos for a region via yt-dlp search with date-sort filter.
Uses the YouTube search sort-by-upload-date URL that reliably returns regional
results. Falls back gracefully to an empty list on error.
"""
region = region.upper()
# CAI%3D = sort by upload date; gl= sets the region
url = f"https://www.youtube.com/results?search_query=trending&sp=CAI%253D&gl={region}"
stdout, _, code = _run([
"yt-dlp",
url,
"--dump-json",
"--flat-playlist",
"--quiet",
"--playlist-end", str(max_results),
*_cookie_args(),
], timeout=60)
results = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
if info.get("_type") in ("url", None) and info.get("id"):
results.append({
"youtube_video_id": info.get("id"),
"title": info.get("title", ""),
"thumbnail_url": _stable_thumbnail(info.get("id")),
"duration_seconds": info.get("duration"),
"published_at": _parse_published(info),
"tags": json.dumps(info.get("tags") or []),
"category": None,
"channel": {
"youtube_channel_id": info.get("channel_id"),
"name": info.get("channel") or info.get("uploader") or "",
"thumbnail_url": None,
},
})
except json.JSONDecodeError:
continue
return results
def _best_thumbnail(thumbnails: list | None) -> str | None:
if not thumbnails:
return None
# pick the one closest to 480px wide
best = sorted(thumbnails, key=lambda t: abs((t.get("width") or 0) - 480))
return best[0].get("url") if best else None
def fetch_video_metadata(video_id: str) -> dict | None:
"""Fetch metadata for a single video by YouTube ID."""
url = f"https://www.youtube.com/watch?v={video_id}"
stdout, _, code = _run([
"yt-dlp",
url,
"--dump-json",
"--no-download",
"--no-playlist",
"--quiet",
*_cookie_args(),
], timeout=30)
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
return _normalize_video(info)
except json.JSONDecodeError:
continue
return None
def _rss_dates(uc_channel_id: str) -> dict[str, datetime]:
"""Fetch publish dates for the 15 most recent videos from YouTube's RSS feed.
Fast, unauthenticated, and returns precise dates. Only works for UC… IDs.
"""
if not uc_channel_id or not uc_channel_id.startswith("UC"):
return {}
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={uc_channel_id}"
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
xml_data = resp.read()
root = ET.fromstring(xml_data)
ns = {
"atom": "http://www.w3.org/2005/Atom",
"yt": "http://www.youtube.com/xml/schemas/2015",
}
dates: dict[str, datetime] = {}
for entry in root.findall("atom:entry", ns):
vid_el = entry.find("yt:videoId", ns)
pub_el = entry.find("atom:published", ns)
if vid_el is not None and pub_el is not None and vid_el.text and pub_el.text:
try:
dt = datetime.fromisoformat(pub_el.text.replace("Z", "+00:00"))
dates[vid_el.text] = dt.replace(tzinfo=None)
except ValueError:
pass
return dates
except Exception:
return {}
def fetch_channel_metadata(channel_id: str, max_videos: int = 30) -> dict | None:
"""Fetch channel info + recent videos.
Uses --dump-single-json --flat-playlist for speed, then enriches video dates
from YouTube's RSS feed (gives precise dates for the 15 most recent videos).
"""
if channel_id.startswith("@"):
url = f"https://www.youtube.com/{channel_id}/videos"
else:
url = f"https://www.youtube.com/channel/{channel_id}/videos"
args = [
"yt-dlp", url,
"--dump-single-json",
"--flat-playlist",
"--quiet",
*_cookie_args(),
]
if max_videos > 0:
args += ["--playlist-end", str(max_videos)]
stdout, _, code = _run(args, timeout=60)
if not stdout.strip():
return None
try:
info = json.loads(stdout.strip())
except json.JSONDecodeError:
return None
if not info.get("id") and not info.get("channel_id"):
return None
channel_info = _normalize_channel(info)
# Fetch RSS dates — fast single HTTP request, precise dates for ≤15 newest videos
uc_id = channel_info.get("youtube_channel_id") or ""
rss = _rss_dates(uc_id)
videos = []
for entry in info.get("entries") or []:
vid_id = entry.get("id")
if not vid_id:
continue
published_at = rss.get(vid_id) or _parse_published(entry)
videos.append({
"youtube_video_id": vid_id,
"title": entry.get("title") or "",
"description": entry.get("description") or None,
"thumbnail_url": _stable_thumbnail(vid_id),
"duration_seconds": entry.get("duration"),
"published_at": published_at,
"tags": json.dumps(entry.get("tags") or []),
"category": (entry.get("categories") or [None])[0],
"channel": {
"youtube_channel_id": channel_info.get("youtube_channel_id"),
"name": channel_info.get("name") or "",
"thumbnail_url": None,
},
})
return {"channel": channel_info, "videos": videos}
def fetch_channel_links(channel_id: str) -> list[str]:
"""Extract linked channel IDs from a channel's about/description."""
if channel_id.startswith("@"):
url = f"https://www.youtube.com/{channel_id}/about"
else:
url = f"https://www.youtube.com/channel/{channel_id}/about"
stdout, _, code = _run([
"yt-dlp",
url,
"--dump-json",
"--no-download",
"--flat-playlist",
"--playlist-end", "1",
"--quiet",
*_cookie_args(),
], timeout=30)
channel_ids = set()
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
info = json.loads(line)
desc = info.get("description", "") or ""
for match in re.finditer(r"youtube\.com/channel/(UC[\w-]+)", desc):
channel_ids.add(match.group(1))
for match in re.finditer(r"youtube\.com/@([\w-]+)", desc):
channel_ids.add(f"@{match.group(1)}")
except json.JSONDecodeError:
continue
return list(channel_ids)
QUALITY_FORMATS = {
"best": "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/22/18/bestvideo+bestaudio/best",
"2160p": "bestvideo[ext=mp4][height<=2160]+bestaudio[ext=m4a]/bestvideo[height<=2160]+bestaudio/best[height<=2160]",
"1440p": "bestvideo[ext=mp4][height<=1440]+bestaudio[ext=m4a]/bestvideo[height<=1440]+bestaudio/best[height<=1440]",
"1080p": "bestvideo[ext=mp4][vcodec^=avc1][height<=1080]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=1080]+bestaudio[ext=m4a]/137+140/22/best[height<=1080]",
"720p": "bestvideo[ext=mp4][vcodec^=avc1][height<=720]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=720]+bestaudio[ext=m4a]/22/best[height<=720]",
"480p": "bestvideo[ext=mp4][vcodec^=avc1][height<=480]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<=480]+bestaudio[ext=m4a]/18/best[height<=480]",
"360p": "bestvideo[ext=mp4][height<=360]+bestaudio[ext=m4a]/18/best[height<=360]",
"240p": "bestvideo[ext=mp4][height<=240]+bestaudio[ext=m4a]/best[height<=240]",
"144p": "bestvideo[ext=mp4][height<=144]+bestaudio[ext=m4a]/best[height<=144]",
}
def detect_resolution(file_path: str) -> str | None:
"""Use ffprobe to get the video stream height and return a label like '1080p'."""
try:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-select_streams", "v:0",
"-show_entries", "stream=height", "-of", "csv=p=0", file_path],
capture_output=True, text=True, timeout=15,
)
height = int(result.stdout.strip())
if height >= 1080: return "1080p"
if height >= 720: return "720p"
if height >= 480: return "480p"
if height >= 360: return "360p"
return f"{height}p"
except Exception:
return None
def predicted_file_path(video_id: str) -> Path:
"""Return the expected output path for a video download."""
return Path(settings.download_path) / f"{video_id}.mp4"
_SEMAPHORE = threading.Semaphore(3)
_semaphore_lock = threading.Lock()
_cookies_browser: str = ""
_cookies_lock = threading.Lock()
def set_max_concurrent(n: int) -> None:
global _SEMAPHORE
with _semaphore_lock:
_SEMAPHORE = threading.Semaphore(max(1, min(n, 10)))
def set_cookies_browser(browser: str) -> None:
global _cookies_browser
with _cookies_lock:
_cookies_browser = browser.strip().lower()
def _cookie_args() -> list[str]:
with _cookies_lock:
b = _cookies_browser
return ["--cookies-from-browser", b] if b else []
def start_download(
video_id: str,
download_id: int,
on_progress: Any,
on_complete: Any,
on_error: Any,
quality: str = "best",
) -> None:
"""Start yt-dlp download in a background thread.
Uses a single progressive MP4 format so the file is playable as it downloads.
--no-part writes directly to the final filename (no .part rename at the end).
"""
url = f"https://www.youtube.com/watch?v={video_id}"
# Predictable output path — lets the player start before download finishes
output_template = str(Path(settings.download_path) / f"{video_id}.%(ext)s")
fmt = QUALITY_FORMATS.get(quality, QUALITY_FORMATS["best"])
def _run_download():
with _SEMAPHORE:
process = subprocess.Popen(
[
"yt-dlp", url,
"-f", fmt,
"--merge-output-format", "mp4",
"--postprocessor-args", "Merger+ffmpeg:-movflags +faststart",
"--embed-metadata", "--embed-thumbnail",
"--no-part", "--no-mtime",
"-o", output_template,
"--newline", "--progress", "--no-colors",
*_cookie_args(),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
file_path = None
stream_index = 0
for line in process.stdout:
line = line.strip()
if re.search(r"\[download\] Destination:", line):
stream_index += 1
m = re.search(r"\[download\]\s+([\d.]+)%", line)
if m:
pct = float(m.group(1))
scaled = pct * 0.85 if stream_index <= 1 else 85.0 + pct * 0.10
on_progress(download_id, min(scaled, 95.0))
m2 = re.search(r"\[(?:download|Merger)\] Destination: (.+)", line)
if m2:
file_path = m2.group(1).strip()
process.wait()
if process.returncode == 0:
resolution = detect_resolution(file_path) if file_path else None
on_complete(download_id, file_path, resolution)
else:
on_error(download_id, f"yt-dlp exited with code {process.returncode}")
thread = threading.Thread(target=_run_download, daemon=True)
thread.start()

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
restart: unless-stopped
volumes:
- ./data/db:/data
- ${DOWNLOAD_PATH:-./downloads}:/downloads
environment:
DATABASE_URL: sqlite:////data/app.db
DOWNLOAD_PATH: /downloads
SECRET_KEY: ${SECRET_KEY:-changeme}
JELLYFIN_URL: ${JELLYFIN_URL:-}
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true}
ports:
- "8000:8000"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "5173:80"
depends_on:
- backend
volumes: {}

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

18
frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

17
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API to backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

3168
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "youtube-hub",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"date-fns": "^4.1.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"vite": "^6.0.3"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

65
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,65 @@
import { BrowserRouter, Routes, Route, Navigate, useParams } from "react-router-dom";
import { AuthProvider, useAuth } from "./hooks/useAuth";
import Layout from "./components/Layout";
import Home from "./pages/Home";
import SearchResults from "./pages/SearchResults";
import ChannelPage from "./pages/Channel";
import DownloadsPage from "./pages/Downloads";
import DiscoveryPage from "./pages/Discovery";
import LoginPage from "./pages/Login";
import WatchPage from "./pages/Watch";
function WatchWrapper() {
const { youtubeVideoId } = useParams();
return <WatchPage key={youtubeVideoId} />;
}
import FollowingPage from "./pages/Following";
import LikedPage from "./pages/Liked";
import SettingsPage from "./pages/Settings";
import ContinueWatchingPage from "./pages/ContinueWatching";
import QueuePage from "./pages/Queue";
import HistoryPage from "./pages/History";
import StatsPage from "./pages/Stats";
import CollectionsPage from "./pages/Collections";
function RequireAuth({ children }) {
const { user, loading } = useAuth();
if (loading) return <div className="flex items-center justify-center h-screen text-zinc-500">Loading</div>;
if (!user) return <Navigate to="/login" replace />;
return children;
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Home />} />
<Route path="search" element={<SearchResults />} />
<Route path="channels/:id" element={<ChannelPage />} />
<Route path="downloads" element={<DownloadsPage />} />
<Route path="following" element={<FollowingPage />} />
<Route path="discovery" element={<DiscoveryPage />} />
<Route path="liked" element={<LikedPage />} />
<Route path="continue-watching" element={<ContinueWatchingPage />} />
<Route path="queue" element={<QueuePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="collections" element={<CollectionsPage />} />
<Route path="watch/:youtubeVideoId" element={<WatchWrapper />} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}

135
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,135 @@
import axios from "axios";
const api = axios.create({ baseURL: "/api" });
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(err);
}
);
export default api;
// Auth
export const login = (username, password) => {
const form = new FormData();
form.append("username", username);
form.append("password", password);
return api.post("/auth/login", form);
};
export const register = (data) => api.post("/auth/register", data);
export const getMe = () => api.get("/auth/me");
// Search
export const search = (q, live = false) =>
api.get("/search", { params: { q, live } });
export const getSearchHistory = () => api.get("/search/history");
// Channels
export const getChannels = () => api.get("/channels");
export const getChannelFeed = (offset = 0) => api.get("/channels/feed", { params: { offset, limit: 24 } });
export const getChannel = (id) => api.get(`/channels/${id}`);
export const syncAllChannels = () => api.post("/channels/sync-all");
export const getChannelVideos = (id) => api.get(`/channels/${id}/videos`);
export const followChannel = (id) => api.post(`/channels/${id}/follow`);
export const unfollowChannel = (id) => api.delete(`/channels/${id}/follow`);
export const indexChannel = (id) => api.post(`/channels/${id}/index`);
export const followChannelByUrl = (data) => api.post("/channels/follow-by-url", data);
export const setChannelAutoDownload = (id, value) => api.patch(`/channels/${id}/auto-download`, { auto_download: value });
export const markChannelsSeen = () => api.post("/channels/mark-seen");
export const followBulk = (handles) => api.post("/channels/follow-bulk", { handles });
export const updateChannelNotes = (id, notes) => api.patch(`/channels/${id}/notes`, { notes });
export const muteChannel = (id) => api.post(`/channels/${id}/mute`);
export const unmuteChannel = (id) => api.delete(`/channels/${id}/mute`);
export const getChannelGroups = () => api.get("/channels/groups");
export const createChannelGroup = (name) => api.post("/channels/groups", { name });
export const deleteChannelGroup = (id) => api.delete(`/channels/groups/${id}`);
export const renameChannelGroup = (id, name) => api.patch(`/channels/groups/${id}`, { name });
export const addChannelToGroup = (groupId, channelId) => api.post(`/channels/groups/${groupId}/channels/${channelId}`);
export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`);
export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action });
// Videos
export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") =>
api.get("/videos/home-feed", { params: { offset: page * limit, limit, mode, ...(duration ? { duration } : {}) } });
export const continueWatching = () => api.get("/videos/continue-watching");
export const longVideos = () => api.get("/videos/long");
export const surpriseMe = () => api.get("/videos/surprise");
export const getVideo = (id) => api.get(`/videos/${id}`);
export const getVideoByYtId = (ytId) => api.get(`/videos/by-yt/${ytId}`);
export const updateProgress = (id, data) => api.patch(`/videos/${id}/progress`, data);
export const toggleQueue = (id) => api.post(`/videos/${id}/queue`);
export const getQueue = () => api.get("/videos/queue");
export const toggleLike = (id) => api.post(`/videos/${id}/like`);
export const getLikedVideos = () => api.get("/videos/liked");
export const rateVideo = (id, rating) => api.post(`/videos/${id}/rate`, { rating });
export const getRelatedVideos = (videoId, mode = "weighted") => api.get(`/videos/${videoId}/related`, { params: { mode } });
export const getHistory = (page = 0, limit = 25, channel_id = null) =>
api.get("/videos/history", { params: { offset: page * limit, limit, ...(channel_id ? { channel_id } : {}) } });
export const getBookmarks = (videoId) => api.get(`/videos/${videoId}/bookmarks`);
export const createBookmark = (videoId, data) => api.post(`/videos/${videoId}/bookmarks`, data);
export const updateBookmark = (videoId, bookmarkId, data) => api.patch(`/videos/${videoId}/bookmarks/${bookmarkId}`, data);
export const deleteBookmark = (videoId, bookmarkId) => api.delete(`/videos/${videoId}/bookmarks/${bookmarkId}`);
export const importChapters = (videoId) => api.post(`/videos/${videoId}/bookmarks/import-chapters`);
export const clearChapters = (videoId) => api.delete(`/videos/${videoId}/bookmarks/clear-chapters`);
// Downloads
export const createDownload = (youtube_video_id, quality) =>
api.post("/downloads", { youtube_video_id, ...(quality ? { quality } : {}) });
export const getDownloads = () => api.get("/downloads");
export const getDownload = (id) => api.get(`/downloads/${id}`);
export const deleteDownload = (id) => api.delete(`/downloads/${id}`);
export const deleteAllDownloads = () => api.delete("/downloads/all");
export const restoreDownload = (id) => api.post(`/downloads/${id}/restore`);
export const downloadChannel = (channelId) => api.post(`/downloads/channel/${channelId}`);
export const downloadFollowing = () => api.post("/downloads/following");
// Export
export const exportData = () => api.get("/export", { responseType: "blob" });
// Settings
export const getSettings = () => api.get("/settings");
export const updateSettings = (data) => api.patch("/settings", data);
// Discovery
export const getDiscovery = (offset = 0, limit = 50) =>
api.get("/discovery", { params: { offset, limit } });
export const dismissDiscoveryVideo = (youtubeVideoId) =>
api.post(`/discovery/videos/${youtubeVideoId}/dismiss`);
export const getDiscoveryVideos = (offset = 0, limit = 50) =>
api.get("/discovery/videos", { params: { offset, limit } });
export const followDiscovery = (channelId) =>
api.post(`/discovery/${channelId}/follow`);
export const dismissDiscovery = (channelId) =>
api.post(`/discovery/${channelId}/dismiss`);
export const refreshDiscovery = () => api.post("/discovery/refresh");
export const getCommunityShelf = () => api.get("/discovery/community");
// Stats
export const getStats = () => api.get("/stats");
// Admin
export const getAdminUsers = () => api.get("/admin/users");
export const deleteAdminUser = (id) => api.delete(`/admin/users/${id}`);
export const getAdminConfig = () => api.get("/admin/config");
export const updateAdminConfig = (data) => api.patch("/admin/config", data);
// Collections
export const getCollections = () => api.get("/collections");
export const createCollection = (name) => api.post("/collections", { name });
export const renameCollection = (id, name) => api.patch(`/collections/${id}`, { name });
export const deleteCollection = (id) => api.delete(`/collections/${id}`);
export const getCollectionVideos = (id) => api.get(`/collections/${id}/videos`);
export const addToCollection = (collectionId, videoId) => api.post(`/collections/${collectionId}/videos`, { video_id: videoId });
export const removeFromCollection = (collectionId, videoId) => api.delete(`/collections/${collectionId}/videos/${videoId}`);

View File

@@ -0,0 +1,79 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { followChannel, unfollowChannel } from "../api";
function formatSubs(n) {
if (!n) return null;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return String(n);
}
export default function ChannelCard({ channel }) {
const qc = useQueryClient();
const navigate = useNavigate();
const channelId = channel.local_channel_id ?? channel.id;
const isFollowed = channel.is_followed ?? (channel.status === "followed");
const followMut = useMutation({
mutationFn: () => isFollowed ? unfollowChannel(channelId) : followChannel(channelId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const subs = formatSubs(channel.subscriber_count);
const meta = [
subs && `${subs} subscribers`,
channel.video_count > 0 && `${channel.video_count} videos`,
].filter(Boolean).join(" · ");
return (
<div
className="bg-zinc-900 rounded-xl p-4 flex gap-4 cursor-pointer hover:bg-zinc-800 transition-colors"
onClick={() => channelId && navigate(`/channels/${channelId}`)}
>
{/* Avatar */}
{channel.thumbnail_url ? (
<img
src={channel.thumbnail_url}
alt={channel.name}
className="w-16 h-16 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-16 h-16 rounded-full bg-zinc-700 flex items-center justify-center text-2xl font-display font-bold text-zinc-400 shrink-0">
{channel.name?.[0]?.toUpperCase()}
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0 flex flex-col gap-1 justify-center">
<p className="font-semibold text-zinc-100 truncate">{channel.name}</p>
{meta && (
<p className="text-xs text-zinc-500">{meta}</p>
)}
{channel.description && (
<p className="text-xs text-zinc-400 line-clamp-2 leading-relaxed mt-0.5">
{channel.description}
</p>
)}
</div>
{/* Follow button */}
{channelId && (
<div className="shrink-0 flex items-center">
<button
onClick={(e) => { e.stopPropagation(); followMut.mutate(); }}
disabled={followMut.isPending}
className={`text-xs font-medium px-4 py-2 rounded-lg transition-colors ${
isFollowed || followMut.isSuccess
? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
: "bg-accent text-black hover:bg-yellow-300"
}`}
>
{isFollowed || followMut.isSuccess ? "Following" : "Follow"}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { Outlet, NavLink, useNavigate, Link, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../hooks/useAuth";
import SearchBar from "./SearchBar";
import { getDownloads, getChannels } from "../api";
function DownloadIndicator() {
const { data } = useQuery({
queryKey: ["downloads"],
queryFn: () => getDownloads().then((r) => r.data),
refetchInterval: (query) => {
const active = (query.state.data ?? []).some(
(d) => d.status === "pending" || d.status === "downloading"
);
return active ? 1500 : 10_000;
},
});
const active = (data ?? []).filter(
(d) => d.status === "pending" || d.status === "downloading"
);
if (!active.length) return null;
const top = active[0];
const pct = top.progress_percent ?? 0;
return (
<Link
to="/downloads"
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300 shrink-0"
title={`${active.length} download${active.length > 1 ? "s" : ""} in progress`}
>
<svg className="w-3.5 h-3.5 animate-spin text-accent" fill="none" viewBox="0 0 24 24">
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span className="font-mono tabular-nums">{pct.toFixed(0)}%</span>
{active.length > 1 && (
<span className="text-zinc-500">+{active.length - 1}</span>
)}
</Link>
);
}
function NavItem({ to, children, badge }) {
return (
<NavLink
to={to}
end
className={({ isActive }) =>
`relative px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
isActive
? "bg-zinc-800 text-zinc-100"
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
}`
}
>
{children}
{badge > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
{badge > 99 ? "99+" : badge}
</span>
)}
</NavLink>
);
}
function DropItem({ to, children, badge }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
`relative flex items-center justify-between gap-4 px-3 py-2 text-sm rounded-lg mx-1 transition-colors ${
isActive
? "bg-zinc-800 text-zinc-100"
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
}`
}
>
<span>{children}</span>
{badge > 0 && (
<span className="min-w-[18px] h-[18px] bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none shrink-0">
{badge > 99 ? "99+" : badge}
</span>
)}
</NavLink>
);
}
function NavDropdown({ label, paths, children }) {
const location = useLocation();
const isGroupActive = paths.some(p => location.pathname.startsWith(p));
return (
<div className="relative group">
<button
className={`flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors select-none ${
isGroupActive
? "bg-zinc-800 text-zinc-100"
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
}`}
>
{label}
<svg className="w-3 h-3 opacity-50 transition-transform duration-150 group-hover:rotate-180" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
{/* invisible bridge so mouse can move from button to panel without gap */}
<div className="absolute top-full left-0 right-0 h-2 bg-transparent" />
<div className="absolute top-[calc(100%+2px)] right-0 z-50
invisible opacity-0 group-hover:visible group-hover:opacity-100
transition-all duration-100 ease-out">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl py-1.5 min-w-[180px]">
{children}
</div>
</div>
</div>
);
}
function NavDivider() {
return <div className="my-1 mx-3 h-px bg-zinc-800" />;
}
function useNewVideosCount() {
const { data: channels = [] } = useQuery({
queryKey: ["channels"],
queryFn: () => getChannels().then((r) => r.data),
staleTime: 60_000,
});
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
}
export default function Layout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const newCount = useNewVideosCount();
return (
<div className="min-h-screen flex flex-col">
{/* Header */}
<header className="sticky top-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
<div className="max-w-screen-xl mx-auto px-4 h-14 flex items-center gap-4">
{/* Logo */}
<button
onClick={() => navigate("/")}
className="font-display font-bold text-lg text-accent shrink-0"
>
YT Hub
</button>
{/* Search */}
<div className="flex-1 max-w-2xl">
<SearchBar />
</div>
{/* Active downloads indicator */}
<DownloadIndicator />
{/* Nav */}
<nav className="hidden sm:flex items-center gap-0.5">
<NavItem to="/">Home</NavItem>
<NavDropdown
label="Watch"
paths={["/continue-watching", "/liked", "/queue", "/history"]}
>
<DropItem to="/continue-watching">Continue watching</DropItem>
<DropItem to="/liked">Liked videos</DropItem>
<DropItem to="/queue">Watch Later</DropItem>
<DropItem to="/collections">Collections</DropItem>
<NavDivider />
<DropItem to="/history">History</DropItem>
</NavDropdown>
<NavDropdown
label="Library"
paths={["/following", "/downloads", "/discovery", "/stats"]}
>
<DropItem to="/following" badge={newCount}>Following</DropItem>
<NavDivider />
<DropItem to="/discovery">Discover</DropItem>
<DropItem to="/downloads">Downloads</DropItem>
<NavDivider />
<DropItem to="/stats">Stats</DropItem>
</NavDropdown>
<NavItem to="/settings">Settings</NavItem>
</nav>
{/* User */}
<button
onClick={logout}
className="ml-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0"
>
{user?.username} · sign out
</button>
</div>
</header>
{/* Page content */}
<main className="flex-1 max-w-screen-xl mx-auto w-full px-4 py-6">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { search, getSearchHistory } from "../api";
function MagnifyIcon() {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
);
}
export default function SearchBar() {
const navigate = useNavigate();
const [params] = useSearchParams();
const [input, setInput] = useState(params.get("q") || "");
const [debouncedQ, setDebouncedQ] = useState("");
const [open, setOpen] = useState(false);
const [focused, setFocused] = useState(false);
const ref = useRef(null);
// Debounce for inline preview
useEffect(() => {
const t = setTimeout(() => setDebouncedQ(input.trim()), 350);
return () => clearTimeout(t);
}, [input]);
// Inline local-only preview results
const { data: preview } = useQuery({
queryKey: ["search-preview", debouncedQ],
queryFn: () => search(debouncedQ, false),
enabled: debouncedQ.length >= 2,
staleTime: 30_000,
});
// Recent searches (shown when focused + empty)
const { data: historyData } = useQuery({
queryKey: ["search-history"],
queryFn: () => getSearchHistory().then(r => r.data),
staleTime: 60_000,
enabled: focused,
});
const previewVideos = preview?.data?.videos?.slice(0, 5) ?? [];
const recentQueries = historyData?.queries ?? [];
// Close dropdown on outside click
useEffect(() => {
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const submit = (q = input) => {
const trimmed = q.trim();
if (!trimmed) return;
setOpen(false);
navigate(`/search?q=${encodeURIComponent(trimmed)}`);
};
return (
<div ref={ref} className="relative w-full">
<div className="flex items-center bg-zinc-900 border border-zinc-700 rounded-lg px-3 gap-2 focus-within:border-zinc-500 transition-colors">
<span className="text-zinc-500">
<MagnifyIcon />
</span>
<input
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
setOpen(true);
}}
onFocus={() => { setFocused(true); if (input.length >= 2) setOpen(true); else setOpen(true); }}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
if (e.key === "Escape") setOpen(false);
}}
placeholder="Search videos, channels…"
className="flex-1 bg-transparent py-2 text-sm text-zinc-100 placeholder-zinc-500 outline-none"
/>
</div>
{/* Recent searches (empty input, focused) */}
{open && !debouncedQ && recentQueries.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl overflow-hidden z-50">
<p className="px-3 pt-2.5 pb-1 text-[10px] text-zinc-600 uppercase tracking-wider font-medium">Recent</p>
{recentQueries.map((q) => (
<button
key={q}
onMouseDown={() => { setOpen(false); navigate(`/search?q=${encodeURIComponent(q)}`); }}
className="w-full flex items-center gap-2.5 px-3 py-2 hover:bg-zinc-800 text-left transition-colors"
>
<svg className="w-3.5 h-3.5 text-zinc-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-zinc-300 truncate">{q}</span>
</button>
))}
</div>
)}
{/* Inline preview dropdown */}
{open && previewVideos.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl overflow-hidden z-50">
{previewVideos.map((v) => (
<button
key={v.youtube_video_id}
onMouseDown={() => { setOpen(false); navigate(`/watch/${v.youtube_video_id}`); }}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-zinc-800 text-left transition-colors"
>
{v.thumbnail_url && (
<img
src={v.thumbnail_url}
alt=""
className="w-16 h-9 object-cover rounded flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-100 truncate">{v.title}</p>
<p className="text-xs text-zinc-500 truncate">{v.channel_name}</p>
</div>
</button>
))}
<button
onMouseDown={() => submit()}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-accent hover:bg-zinc-800 border-t border-zinc-800 transition-colors"
>
<MagnifyIcon />
Search YouTube for "{input}"
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import VideoCard from "./VideoCard";
export default function Shelf({ title, videos, action, emptyText }) {
if (!videos?.length && emptyText) {
return (
<section>
<h2 className="font-display font-semibold text-lg text-zinc-300 mb-3">{title}</h2>
<p className="text-sm text-zinc-600">{emptyText}</p>
</section>
);
}
if (!videos?.length) return null;
return (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display font-semibold text-lg text-zinc-300">{title}</h2>
{action}
</div>
<div className="flex gap-4 overflow-x-auto pb-2 -mx-1 px-1">
{videos.map((v) => (
<div key={v.youtube_video_id || v.id} className="w-64 shrink-0">
<VideoCard video={v} />
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
export default function SortPicker({ value, onChange, options }) {
return (
<div className="relative flex items-center">
<svg
className="absolute left-2.5 w-3 h-3 text-zinc-500 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
</svg>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-7 pr-3 py-1.5 bg-zinc-800 border border-zinc-700/60 rounded-lg text-xs text-zinc-300 focus:outline-none focus:border-zinc-500 cursor-pointer"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,365 @@
import clsx from "clsx";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createDownload, toggleQueue, toggleLike, dismissDiscovery, getSettings } from "../api";
function formatDuration(secs) {
if (!secs) return null;
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatDate(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
function IconBtn({ onClick, title, active, pending, children }) {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick(e); }}
title={title}
className={clsx(
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-150",
active
? "text-accent"
: "text-zinc-600 hover:text-zinc-200",
pending && "opacity-60 cursor-default",
)}
>
{children}
</button>
);
}
function DownloadIcon({ active }) {
return active ? (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 20h14v-2H5v2z" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
);
}
function HeartIcon({ active }) {
return active ? (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
);
}
function QueueIcon({ active }) {
return (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2.5 : 2}
d="M4 6h16M4 11h16M4 16h10" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2.5 : 2}
d="M16 18l4-2.5-4-2.5v5z" />
</svg>
);
}
function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, className }) {
return (
<div className={clsx("relative bg-zinc-800 overflow-hidden", className)}>
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt={video.title}
className={clsx(
"w-full h-full object-cover transition-all duration-300",
"group-hover:scale-[1.03]",
isWatched && "opacity-50",
)}
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-zinc-700">
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
)}
{/* Dismiss */}
{video.is_recommended && !calmMode && (
<button
onClick={(e) => { e.stopPropagation(); onDismiss?.(); }}
title="Not interested"
className="absolute top-2 right-2 z-10 w-5 h-5 rounded-full bg-black/60 text-zinc-400 hover:text-white hover:bg-black flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<svg className="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Duration */}
{duration && (
<span className="absolute bottom-2 right-2 bg-black/75 text-white text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono tabular-nums">
{duration}
</span>
)}
{/* Resolution */}
{video.download_resolution && (
<span className="absolute bottom-2 left-2 text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono text-accent bg-black/75">
{video.download_resolution}
</span>
)}
{/* Watched dot */}
{isWatched && (
<span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_rgba(var(--color-accent-rgb),0.8)]" />
)}
{/* Play hover overlay */}
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div className="w-11 h-11 rounded-full bg-white/15 backdrop-blur-sm flex items-center justify-center border border-white/20">
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{/* Progress bar */}
{video.watch_progress_seconds > 0 && video.duration_seconds > 0 && (
<div className="absolute bottom-0 inset-x-0 h-[3px] bg-white/10">
<div
className="h-full bg-accent"
style={{ width: `${Math.min(video.watch_progress_seconds / video.duration_seconds, 1) * 100}%` }}
/>
</div>
)}
</div>
);
}
export default function VideoCard({ video, size = "md", onDismiss, variant = "grid" }) {
const navigate = useNavigate();
const qc = useQueryClient();
const { data: appSettings } = useQuery({
queryKey: ["settings"],
queryFn: () => getSettings().then(r => r.data),
staleTime: 5 * 60_000,
});
const calmMode = appSettings?.calm_mode ?? false;
const channels = qc.getQueryData(["channels"]) ?? [];
const channelMeta = channels.find(c => c.id === video.channel_id) ?? null;
const channelNote = channelMeta?.notes || null;
const isDormant = channelMeta?.last_published_at
? (Date.now() - new Date(channelMeta.last_published_at)) / (1000 * 60 * 60 * 24) > 180
: false;
const internalId = video.id ?? video.local_video_id ?? null;
const isDownloaded = video.is_downloaded;
const isWatched = video.is_watched;
const duration = formatDuration(video.duration_seconds);
const date = formatDate(video.published_at);
const [downloaded, setDownloaded] = useState(isDownloaded);
const [queued, setQueued] = useState(video.queued ?? false);
const [liked, setLiked] = useState(video.liked ?? false);
const dlMut = useMutation({
mutationFn: () => createDownload(video.youtube_video_id),
onSuccess: () => { setDownloaded(true); qc.invalidateQueries({ queryKey: ["downloads"] }); },
});
const qMut = useMutation({
mutationFn: () => toggleQueue(internalId),
onSuccess: (res) => { setQueued(res.data.queued); qc.invalidateQueries({ queryKey: ["videos"] }); },
});
const likeMut = useMutation({
mutationFn: () => toggleLike(internalId),
onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); },
});
const dismissMut = useMutation({
mutationFn: () => dismissDiscovery(video.channel_id),
onSuccess: () => onDismiss?.(video),
});
const actions = (
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
<IconBtn onClick={() => navigate(`/watch/${video.youtube_video_id}`)} title="Watch">
<svg className="w-3.5 h-3.5 ml-px" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</IconBtn>
<IconBtn
onClick={() => { if (!downloaded && !dlMut.isPending) dlMut.mutate(); }}
title={downloaded ? "Downloaded" : dlMut.isPending ? "Downloading…" : "Download"}
active={downloaded}
pending={dlMut.isPending}
>
{dlMut.isPending ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : <DownloadIcon active={downloaded} />}
</IconBtn>
<IconBtn
onClick={() => { if (internalId && !qMut.isPending) qMut.mutate(); }}
title={!internalId ? "Save video first to queue it" : queued ? "Remove from Watch Later" : "Watch Later"}
active={queued}
pending={qMut.isPending}
>
<QueueIcon active={queued} />
</IconBtn>
<IconBtn
onClick={() => { if (internalId && !likeMut.isPending) likeMut.mutate(); }}
title={!internalId ? "Watch video first to like it" : liked ? "Unlike" : "Like"}
active={liked}
pending={likeMut.isPending}
>
<HeartIcon active={liked} />
</IconBtn>
{(queued || downloaded) && (
<span className="ml-1 text-[10px] text-zinc-700 font-medium">
{queued ? "later" : "saved"}
</span>
)}
</div>
);
// ── List variant ─────────────────────────────────────────────────────────
if (variant === "list") {
return (
<div
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
className="group flex gap-5 px-3 py-3.5 rounded-2xl cursor-pointer hover:bg-zinc-800/50 transition-colors duration-150"
>
<ThumbnailBlock
video={video}
isWatched={isWatched}
duration={duration}
calmMode={calmMode}
onDismiss={() => dismissMut.mutate()}
className="w-56 sm:w-72 aspect-video rounded-xl shrink-0"
/>
<div className="flex flex-col min-w-0 flex-1 py-0.5 gap-2">
{/* Title */}
<h3 className="font-semibold text-[15px] leading-snug text-zinc-50 line-clamp-2">
{video.title}
</h3>
{/* Channel · date · badges */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[12px] text-zinc-400 truncate">{video.channel_name}</span>
{date && <span className="text-zinc-700 text-[12px]">·</span>}
{date && <span className="text-[12px] text-zinc-600 shrink-0">{date}</span>}
{video.is_recommended && !calmMode && (
<span className="text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
Discover
</span>
)}
{isDormant && !calmMode && (
<span title="No uploads in 6+ months" className="text-[10px] text-zinc-700 tracking-widest">zzz</span>
)}
{channelNote && (
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
</svg>
</span>
)}
</div>
{/* Description */}
{video.description ? (
<p className="text-[12px] leading-relaxed text-zinc-500 line-clamp-3 flex-1">
{video.description.replace(/\n+/g, " ")}
</p>
) : (
<div className="flex-1" />
)}
{/* Actions — fade in on hover */}
<div className="opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 mt-auto">
{actions}
</div>
</div>
</div>
);
}
// ── Grid variant ─────────────────────────────────────────────────────────
return (
<div
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
className={clsx(
"group relative flex flex-col cursor-pointer rounded-2xl overflow-hidden",
"bg-zinc-900 hover:bg-zinc-800/80 transition-colors duration-150",
size === "sm" && "text-xs",
)}
>
<ThumbnailBlock
video={video}
isWatched={isWatched}
duration={duration}
calmMode={calmMode}
onDismiss={() => dismissMut.mutate()}
className="aspect-video"
/>
<div className="flex flex-col gap-2 p-3 flex-1">
{/* Title */}
<p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2">
{video.title}
</p>
{/* Channel + date */}
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
<div className="flex items-center gap-1.5 shrink-0">
{channelNote && (
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
</svg>
</span>
)}
{isDormant && !calmMode && (
<span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest">zzz</span>
)}
{date && <span className="text-[11px] text-zinc-700">{date}</span>}
</div>
</div>
{/* Badges */}
{(video.is_recommended && !calmMode) && (
<span className="self-start text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
Discover
</span>
)}
{/* Actions — fade in on hover */}
<div className="mt-auto pt-1.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 border-t border-zinc-800/80">
{actions}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,277 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getVideoByYtId, updateProgress, createDownload, followChannelByUrl, getDownload } from "../api";
function formatDuration(s) {
if (!s) return "";
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
function CloseIcon() {
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
function YoutubeEmbed({ youtubeId, startAt, onTimeUpdate }) {
useEffect(() => {
const handler = (e) => {
if (e.origin !== "https://www.youtube.com") return;
try {
const data = JSON.parse(e.data);
if (data.event === "infoDelivery" && data.info?.currentTime != null) {
onTimeUpdate(Math.floor(data.info.currentTime));
}
} catch {}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [onTimeUpdate]);
const start = startAt > 10 ? startAt : 0;
const src = `https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0&modestbranding=1&enablejsapi=1&start=${start}&origin=${encodeURIComponent(window.location.origin)}`;
return (
<iframe
src={src}
className="w-full aspect-video rounded-lg bg-black"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
);
}
function LocalVideo({ src, startAt, onTimeUpdate }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current && startAt > 10) {
ref.current.currentTime = startAt;
}
}, []); // only on mount
return (
<video
ref={ref}
src={src}
controls
autoPlay
className="w-full aspect-video rounded-lg bg-black"
onTimeUpdate={() => {
if (ref.current) onTimeUpdate(Math.floor(ref.current.currentTime));
}}
/>
);
}
function DownloadProgress({ pct, status }) {
const label = status === "pending" ? "Queued…" : `Downloading ${pct.toFixed(0)}%`;
return (
<div className="flex items-center gap-3">
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-zinc-500 shrink-0 font-mono w-28">{label}</span>
</div>
);
}
export default function VideoPlayer() {
const [params, setParams] = useSearchParams();
const qc = useQueryClient();
const youtubeId = params.get("play");
const urlTitle = params.get("pt");
const urlChannel = params.get("pc");
const [currentTime, setCurrentTime] = useState(0);
const [downloadId, setDownloadId] = useState(null);
const [switchedToLocal, setSwitchedToLocal] = useState(false);
const saveTimerRef = useRef(null);
const initiatedRef = useRef(null); // track which video we triggered download for
// ── Video metadata ────────────────────────────────────────────────────────
const { data: video, refetch: refetchVideo } = useQuery({
queryKey: ["video-play", youtubeId],
queryFn: () =>
getVideoByYtId(youtubeId)
.then((r) => r.data)
.catch((err) => (err.response?.status === 404 ? null : Promise.reject(err))),
enabled: !!youtubeId,
staleTime: 0,
});
// ── Download polling ──────────────────────────────────────────────────────
const { data: dlStatus } = useQuery({
queryKey: ["download-status", downloadId],
queryFn: () => getDownload(downloadId).then((r) => r.data),
enabled: !!downloadId,
refetchInterval: (query) => {
const s = query.state.data?.status;
return s === "complete" || s === "failed" ? false : 1500;
},
});
// When download finishes, re-fetch video to get local_file_url and auto-switch
useEffect(() => {
if (dlStatus?.status === "complete" && !switchedToLocal) {
refetchVideo().then(({ data }) => {
if (data?.local_file_url) setSwitchedToLocal(true);
});
}
}, [dlStatus?.status, switchedToLocal, refetchVideo]);
// ── Trigger download on open ──────────────────────────────────────────────
const downloadMut = useMutation({
mutationFn: (ytId) => createDownload(ytId),
onSuccess: (res) => {
const dl = res.data;
setDownloadId(dl.id);
// If it came back complete already (was pre-downloaded), just switch now
if (dl.status === "complete") {
refetchVideo().then(({ data }) => {
if (data?.local_file_url) setSwitchedToLocal(true);
});
}
},
});
useEffect(() => {
if (!youtubeId || initiatedRef.current === youtubeId) return;
initiatedRef.current = youtubeId;
setSwitchedToLocal(false);
setCurrentTime(0);
setDownloadId(null);
// Small delay so the modal renders before the fetch starts
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
return () => clearTimeout(t);
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Progress saving ───────────────────────────────────────────────────────
const followMut = useMutation({
mutationFn: () => followChannelByUrl({ youtube_channel_id: video?.channel_youtube_id }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const handleTimeUpdate = useCallback((secs) => {
setCurrentTime(secs);
clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
if (video?.id) {
const duration = video.duration_seconds ?? 0;
const watched = duration > 0 && secs >= duration * 0.9;
updateProgress(video.id, { watch_progress_seconds: secs, watched });
}
}, 10_000);
}, [video]);
const close = useCallback(() => {
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
setSwitchedToLocal(false);
clearTimeout(saveTimerRef.current);
}, [setParams]);
useEffect(() => {
const handler = (e) => { if (e.key === "Escape") close(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [close]);
useEffect(() => () => clearTimeout(saveTimerRef.current), []);
if (!youtubeId) return null;
const title = video?.title ?? urlTitle ?? youtubeId;
const channelName = video?.channel_name ?? urlChannel;
const startAt = video?.watch_progress_seconds ?? 0;
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
const localUrl = switchedToLocal ? video?.local_file_url : null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) close(); }}
>
<div className="relative w-full max-w-4xl flex flex-col gap-3 max-h-[95vh] overflow-y-auto">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="font-display font-semibold text-white text-lg leading-snug line-clamp-2">
{title}
</h2>
{channelName && (
<p className="text-sm text-zinc-400 mt-0.5">{channelName}</p>
)}
</div>
<button onClick={close} className="shrink-0 text-zinc-400 hover:text-white transition-colors p-1">
<CloseIcon />
</button>
</div>
{/* Player — local file once ready, YouTube embed while downloading */}
{localUrl ? (
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
) : (
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
)}
{/* Download progress bar (shows while downloading, disappears when done) */}
{isDownloading && (
<DownloadProgress
pct={dlStatus.progress_percent ?? 0}
status={dlStatus.status}
/>
)}
{/* Status / source indicator */}
<div className="flex items-center gap-3 flex-wrap">
{localUrl ? (
<span className="text-xs text-accent font-medium"> Playing local file</span>
) : isDownloading ? (
<span className="text-xs text-zinc-500">Watching on YouTube · switching to local when ready</span>
) : dlStatus?.status === "failed" ? (
<span className="text-xs text-red-400">Download failed watching on YouTube</span>
) : null}
{/* Follow channel */}
{video?.channel_youtube_id && (
<button
onClick={() => followMut.mutate()}
disabled={followMut.isPending || followMut.isSuccess}
className="ml-auto text-xs font-medium px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
{followMut.isSuccess ? "Following ✓" : "Follow channel"}
</button>
)}
{/* Duration */}
{video?.duration_seconds && (
<span className="text-xs text-zinc-600 font-mono">
{formatDuration(currentTime || startAt)} / {formatDuration(video.duration_seconds)}
</span>
)}
</div>
{/* Description */}
{video?.description && (
<details>
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300 transition-colors select-none">
Description
</summary>
<p className="text-sm text-zinc-400 mt-2 whitespace-pre-line leading-relaxed max-h-40 overflow-y-auto">
{video.description}
</p>
</details>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useState, useEffect, createContext, useContext } from "react";
import { getMe, login as apiLogin, register as apiRegister } from "../api";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
setLoading(false);
return;
}
getMe()
.then((res) => setUser(res.data))
.catch(() => localStorage.removeItem("token"))
.finally(() => setLoading(false));
}, []);
const login = async (username, password) => {
const res = await apiLogin(username, password);
localStorage.setItem("token", res.data.access_token);
const me = await getMe();
setUser(me.data);
};
const register = async (username, email, password) => {
const res = await apiRegister({ username, email, password });
localStorage.setItem("token", res.data.access_token);
const me = await getMe();
setUser(me.data);
};
const logout = () => {
localStorage.removeItem("token");
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -0,0 +1,25 @@
import { useSearchParams } from "react-router-dom";
export function usePlayer() {
const [params, setParams] = useSearchParams();
const play = (youtubeVideoId, meta = {}) => {
setParams((p) => {
p.set("play", youtubeVideoId);
if (meta.title) p.set("pt", meta.title);
if (meta.channel_name) p.set("pc", meta.channel_name);
return p;
});
};
const close = () => {
setParams((p) => {
p.delete("play");
p.delete("pt");
p.delete("pc");
return p;
});
};
return { play, close, currentId: params.get("play") };
}

27
frontend/src/index.css Normal file
View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
scrollbar-width: thin;
scrollbar-color: #3f3f46 transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-thumb {
background-color: #3f3f46;
border-radius: 3px;
}
}
@layer utilities {
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}

22
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,162 @@
import { useState, useMemo } from "react";
import { useParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getChannel, getChannelVideos, followChannel, unfollowChannel, indexChannel, downloadChannel } from "../api";
import VideoCard from "../components/VideoCard";
import SortPicker from "../components/SortPicker";
function formatSubs(n) {
if (!n) return null;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
}
const VIDEO_SORTS = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "title", label: "Title AZ" },
{ value: "unwatched", label: "Unwatched first" },
];
function sortVideos(items, sort) {
const arr = [...items];
if (sort === "oldest") return arr.sort((a, b) => new Date(a.published_at ?? 0) - new Date(b.published_at ?? 0));
if (sort === "title") return arr.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
if (sort === "unwatched") return arr.sort((a, b) => Number(a.is_watched) - Number(b.is_watched));
return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0));
}
export default function ChannelPage() {
const { id } = useParams();
const qc = useQueryClient();
const { data: channel, isLoading: loadingChannel } = useQuery({
queryKey: ["channel", id],
queryFn: () => getChannel(id).then((r) => r.data),
});
const { data: videos, isLoading: loadingVideos } = useQuery({
queryKey: ["channel-videos", id],
queryFn: () => getChannelVideos(id).then((r) => r.data),
});
const followMut = useMutation({
mutationFn: () =>
channel?.status === "followed" ? unfollowChannel(id) : followChannel(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", id] });
qc.invalidateQueries({ queryKey: ["channels"] });
},
});
const indexMut = useMutation({
mutationFn: () => indexChannel(id),
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["channel-videos", id] }), 4000),
});
const [dlResult, setDlResult] = useState(null);
const [videoSort, setVideoSort] = useState("newest");
const dlMut = useMutation({
mutationFn: () => downloadChannel(id),
onSuccess: (res) => {
setDlResult(res.data.queued);
qc.invalidateQueries({ queryKey: ["downloads"] });
},
});
if (loadingChannel) {
return (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!channel) return <p className="text-zinc-500">Channel not found.</p>;
const isFollowed = channel.status === "followed";
return (
<div className="flex flex-col gap-8">
{/* Channel header */}
<div className="flex items-start gap-5">
{channel.thumbnail_url ? (
<img
src={channel.thumbnail_url}
alt={channel.name}
className="w-20 h-20 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-20 h-20 rounded-full bg-zinc-800 flex items-center justify-center text-3xl font-display font-bold text-zinc-400 shrink-0">
{channel.name?.[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<h1 className="font-display font-bold text-2xl text-zinc-100">{channel.name}</h1>
<p className="text-sm text-zinc-500 mt-1">
{[
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subscribers`,
`${channel.video_count} videos indexed`,
].filter(Boolean).join(" · ")}
</p>
{channel.description && (
<p className="text-sm text-zinc-400 mt-2 line-clamp-2">{channel.description}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
{dlResult != null && (
<span className="text-sm text-accent font-mono">
{dlResult === 0 ? "Already up to date" : `${dlResult} queued`}
</span>
)}
<button
onClick={() => dlMut.mutate()}
disabled={dlMut.isPending}
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-black hover:bg-yellow-300 transition-colors disabled:opacity-60 flex items-center gap-2"
>
{dlMut.isPending ? "Queuing…" : "Download all"}
</button>
<button
onClick={() => indexMut.mutate()}
disabled={indexMut.isPending || indexMut.isSuccess}
className="text-sm font-medium px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
{indexMut.isPending ? "Indexing…" : indexMut.isSuccess ? "Done ✓" : "Re-index"}
</button>
<button
onClick={() => followMut.mutate()}
disabled={followMut.isPending}
className={`text-sm font-medium px-4 py-2 rounded-lg transition-colors ${
isFollowed
? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
}`}
>
{isFollowed ? "Following" : "Follow"}
</button>
</div>
</div>
{/* Video grid */}
{loadingVideos ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : videos?.length ? (
<>
<div className="flex justify-end">
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{sortVideos(videos, videoSort).map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} />
))}
</div>
</>
) : (
<p className="text-zinc-500 text-sm">No videos indexed yet. Hit Re-index to fetch them.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
getCollections, createCollection, renameCollection, deleteCollection,
getCollectionVideos, removeFromCollection,
} from "../api";
import VideoCard from "../components/VideoCard";
function formatDate(d) {
if (!d) return "";
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function ThumbnailMosaic({ thumbnails }) {
const t = thumbnails.slice(0, 4);
if (t.length === 0) {
return (
<div className="w-full h-full flex items-center justify-center text-zinc-700">
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z"/>
</svg>
</div>
);
}
if (t.length === 1) return <img src={t[0]} className="w-full h-full object-cover" alt="" />;
return (
<div className="grid grid-cols-2 w-full h-full gap-px">
{t.map((src, i) => (
<img key={i} src={src} className="w-full h-full object-cover" alt="" />
))}
</div>
);
}
function CollectionCard({ col, onOpen, onRename, onDelete }) {
const [confirming, setConfirming] = useState(false);
return (
<div
onClick={() => onOpen(col)}
className="group bg-zinc-900 rounded-2xl overflow-hidden cursor-pointer hover:bg-zinc-800/80 transition-colors"
>
<div className="aspect-video bg-zinc-800 overflow-hidden">
<ThumbnailMosaic thumbnails={col.thumbnails} />
</div>
<div className="p-3 flex flex-col gap-1">
<p className="font-medium text-sm text-zinc-100 truncate">{col.name}</p>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500">{col.video_count} video{col.video_count !== 1 ? "s" : ""}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
<button
onClick={() => onRename(col)}
className="p-1 text-zinc-600 hover:text-zinc-300 transition-colors"
title="Rename"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
{confirming ? (
<button onClick={() => { onDelete(col.id); setConfirming(false); }} className="text-[11px] text-red-400 hover:text-red-300 px-1">confirm</button>
) : (
<button onClick={() => setConfirming(true)} className="p-1 text-zinc-600 hover:text-red-400 transition-colors" title="Delete">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
</div>
</div>
);
}
export default function CollectionsPage() {
const qc = useQueryClient();
const [open, setOpen] = useState(null); // collection being viewed
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState(null); // {id, name}
const { data: collections = [], isLoading } = useQuery({
queryKey: ["collections"],
queryFn: () => getCollections().then(r => r.data),
staleTime: 30_000,
});
const { data: openData } = useQuery({
queryKey: ["collection-videos", open?.id],
queryFn: () => getCollectionVideos(open.id).then(r => r.data),
enabled: !!open,
staleTime: 10_000,
});
const createMut = useMutation({
mutationFn: (name) => createCollection(name),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["collections"] }); setCreating(false); setNewName(""); },
});
const renameMut = useMutation({
mutationFn: ({ id, name }) => renameCollection(id, name),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["collections"] }); setRenaming(null); },
});
const deleteMut = useMutation({
mutationFn: (id) => deleteCollection(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["collections"] }); if (open) setOpen(null); },
});
const removeMut = useMutation({
mutationFn: ({ videoId }) => removeFromCollection(open.id, videoId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["collection-videos", open?.id] }),
});
if (open) {
const videos = openData?.videos ?? [];
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<button onClick={() => setOpen(null)} className="text-zinc-500 hover:text-zinc-200 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 className="font-display font-bold text-2xl text-zinc-100">{open.name}</h1>
<span className="text-zinc-600 text-sm">{videos.length} video{videos.length !== 1 ? "s" : ""}</span>
</div>
{videos.length === 0 ? (
<p className="text-zinc-500 text-sm py-12 text-center">No videos in this collection yet.<br/>Add them from the video watch page.</p>
) : (
<div className="flex flex-col gap-2">
{videos.map(v => (
<div key={v.youtube_video_id} className="flex items-start gap-3 group">
<div className="flex-1 min-w-0">
<VideoCard video={v} variant="list" />
</div>
<button
onClick={() => removeMut.mutate({ videoId: v.id })}
title="Remove from collection"
className="shrink-0 mt-4 p-1.5 rounded-lg text-zinc-700 hover:text-red-400 hover:bg-zinc-800 transition-colors opacity-0 group-hover:opacity-100"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="font-display font-bold text-2xl text-zinc-100">Collections</h1>
<button
onClick={() => setCreating(true)}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
New collection
</button>
</div>
{creating && (
<div className="flex items-center gap-3 p-4 bg-zinc-900 rounded-2xl">
<input
autoFocus
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && newName.trim()) createMut.mutate(newName.trim()); if (e.key === "Escape") { setCreating(false); setNewName(""); } }}
placeholder="Collection name…"
className="flex-1 bg-zinc-800 text-zinc-100 text-sm rounded-lg px-3 py-2 focus:outline-none border border-zinc-700 focus:border-zinc-500"
/>
<button onClick={() => { if (newName.trim()) createMut.mutate(newName.trim()); }} disabled={!newName.trim() || createMut.isPending} className="px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium disabled:opacity-50">Create</button>
<button onClick={() => { setCreating(false); setNewName(""); }} className="px-3 py-2 text-zinc-500 hover:text-zinc-300 text-sm">Cancel</button>
</div>
)}
{renaming && (
<div className="flex items-center gap-3 p-4 bg-zinc-900 rounded-2xl">
<input
autoFocus
value={renaming.name}
onChange={e => setRenaming(r => ({ ...r, name: e.target.value }))}
onKeyDown={e => { if (e.key === "Enter") renameMut.mutate(renaming); if (e.key === "Escape") setRenaming(null); }}
className="flex-1 bg-zinc-800 text-zinc-100 text-sm rounded-lg px-3 py-2 focus:outline-none border border-zinc-700 focus:border-zinc-500"
/>
<button onClick={() => renameMut.mutate(renaming)} disabled={renameMut.isPending} className="px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium disabled:opacity-50">Save</button>
<button onClick={() => setRenaming(null)} className="px-3 py-2 text-zinc-500 hover:text-zinc-300 text-sm">Cancel</button>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-7 h-7 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : collections.length === 0 ? (
<p className="text-zinc-500 text-sm text-center py-16">No collections yet. Create one to organise your videos.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{collections.map(col => (
<CollectionCard
key={col.id}
col={col}
onOpen={setOpen}
onRename={col => setRenaming({ id: col.id, name: col.name })}
onDelete={id => deleteMut.mutate(id)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useQuery } from "@tanstack/react-query";
import { continueWatching } from "../api";
import VideoCard from "../components/VideoCard";
export default function ContinueWatchingPage() {
const { data: videos = [], isLoading } = useQuery({
queryKey: ["continue-watching"],
queryFn: () => continueWatching().then((r) => r.data),
staleTime: 30_000,
});
return (
<div className="flex flex-col gap-6">
<h1 className="font-display font-bold text-2xl text-zinc-100">Continue Watching</h1>
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : videos.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-24 text-center">
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center">
<svg className="w-7 h-7 text-zinc-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
<div>
<p className="text-zinc-300 font-medium">Nothing in progress</p>
<p className="text-zinc-500 text-sm mt-1">
Videos you've started but not finished will appear here.
</p>
</div>
</div>
) : (
<>
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{videos.map((v) => (
<VideoCard
key={v.youtube_video_id}
video={{
...v,
is_watched: false,
}}
/>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,356 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getDiscovery, getDiscoveryVideos,
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
} from "../api";
import VideoCard from "../components/VideoCard";
const PAGE_SIZE = 50;
const SOURCE_LABELS = {
search: "Based on your channels",
graph: "Related to channels you follow",
community: "Popular with other users",
category: "Similar category",
liked: "Related to videos you liked",
};
function formatSubs(n) {
if (!n) return null;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
}
function avatarColor(name) {
if (!name) return "#52525b";
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
return `hsl(${h}, 55%, 42%)`;
}
function ChannelCard({ item }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [gone, setGone] = useState(false);
const followMut = useMutation({
mutationFn: () => followDiscovery(item.channel_id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] });
setTimeout(() => {
setGone(true);
qc.invalidateQueries({ queryKey: ["discovery"] });
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
}, 600);
},
});
const dismissMut = useMutation({
mutationFn: () => dismissDiscovery(item.channel_id),
onSuccess: () => {
setTimeout(() => {
setGone(true);
qc.invalidateQueries({ queryKey: ["discovery"] });
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
}, 300);
},
});
const [featured, ...rest] = item.preview_videos ?? [];
const subs = formatSubs(item.subscriber_count);
const busy = followMut.isPending || dismissMut.isPending;
if (gone) return null;
return (
<div className={`rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden flex flex-col transition-opacity duration-300 ${
followMut.isSuccess || dismissMut.isSuccess ? "opacity-0 pointer-events-none" : "opacity-100"
}`}>
{/* Featured thumbnail */}
{featured ? (
<div
className="relative aspect-video bg-zinc-800 overflow-hidden cursor-pointer group/thumb"
onClick={() => navigate(`/channels/${item.channel_id}`)}
>
<img
src={featured.thumbnail_url}
alt={featured.title}
className="w-full h-full object-cover group-hover/thumb:scale-105 transition-transform duration-300"
loading="lazy"
/>
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-zinc-900/80 to-transparent" />
<div className="absolute bottom-2 left-3">
{item.thumbnail_url ? (
<img src={item.thumbnail_url} alt={item.name}
className="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-900" />
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white text-sm ring-2 ring-zinc-900"
style={{ backgroundColor: avatarColor(item.name) }}>
{item.name?.[0]?.toUpperCase()}
</div>
)}
</div>
</div>
) : (
<div
className="h-20 cursor-pointer flex items-end px-3 pb-2"
style={{ background: `linear-gradient(135deg, ${avatarColor(item.name)}44, ${avatarColor(item.name)}22)` }}
onClick={() => navigate(`/channels/${item.channel_id}`)}
>
{item.thumbnail_url ? (
<img src={item.thumbnail_url} alt={item.name}
className="w-10 h-10 rounded-full object-cover ring-2 ring-zinc-900" />
) : (
<div className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white text-base ring-2 ring-zinc-900"
style={{ backgroundColor: avatarColor(item.name) }}>
{item.name?.[0]?.toUpperCase()}
</div>
)}
</div>
)}
{/* Info */}
<div className="p-3 flex flex-col gap-2 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<button
onClick={() => navigate(`/channels/${item.channel_id}`)}
className="font-semibold text-sm text-zinc-100 hover:text-white text-left leading-tight line-clamp-1"
>
{item.name}
</button>
<p className="text-xs text-zinc-500 mt-0.5 truncate">
{[subs && `${subs} subscribers`, SOURCE_LABELS[item.source] ?? "Recommended"].filter(Boolean).join(" · ")}
</p>
</div>
{!followMut.isSuccess && (
<button
onClick={() => dismissMut.mutate()}
disabled={busy}
title="Not interested"
className="p-1 rounded-md text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors disabled:opacity-40 shrink-0"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{rest.length > 0 && (
<ul className="flex flex-col gap-0.5">
{rest.slice(0, 2).map((v, i) => (
<li key={i} className="text-xs text-zinc-500 truncate">· {v.title}</li>
))}
</ul>
)}
{!featured && item.description && (
<p className="text-xs text-zinc-500 line-clamp-2">{item.description}</p>
)}
<div className="mt-auto pt-1">
{followMut.isSuccess ? (
<p className="text-xs text-accent font-medium text-center py-1">Following </p>
) : (
<button
onClick={() => followMut.mutate()}
disabled={busy}
className="w-full py-1.5 rounded-lg bg-accent text-black text-xs font-semibold hover:bg-yellow-300 transition-colors disabled:opacity-50"
>
Follow
</button>
)}
</div>
</div>
</div>
);
}
function Tab({ active, onClick, children, count }) {
return (
<button
onClick={onClick}
className={[
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2",
active
? "border-accent text-zinc-100"
: "border-transparent text-zinc-500 hover:text-zinc-300",
].join(" ")}
>
{children}
{count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full ${active ? "bg-accent/20 text-accent" : "bg-zinc-800 text-zinc-500"}`}>
{count}
</span>
)}
</button>
);
}
export default function DiscoveryPage() {
const qc = useQueryClient();
const [tab, setTab] = useState("channels");
const [channelPage, setChannelPage] = useState(0);
const [videoPage, setVideoPage] = useState(0);
const [dismissedVideos, setDismissedVideos] = useState(new Set());
const { data: channels = [], isLoading: loadingChannels } = useQuery({
queryKey: ["discovery", channelPage],
queryFn: () => getDiscovery(channelPage * PAGE_SIZE, PAGE_SIZE).then((r) => r.data),
staleTime: 0,
placeholderData: (prev) => prev,
});
const { data: videos = [], isLoading: loadingVideos } = useQuery({
queryKey: ["discovery-videos", videoPage],
queryFn: () => getDiscoveryVideos(videoPage * PAGE_SIZE, PAGE_SIZE).then((r) => r.data),
staleTime: 0,
placeholderData: (prev) => prev,
});
const refreshMut = useMutation({
mutationFn: refreshDiscovery,
onSuccess: () => setTimeout(() => {
qc.invalidateQueries({ queryKey: ["discovery"] });
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
}, 8000),
});
const handleDismissVideo = (video) => {
setDismissedVideos(prev => new Set([...prev, video.youtube_video_id]));
dismissDiscoveryVideo(video.youtube_video_id).then(() => {
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
});
};
const visibleVideos = videos.filter(v => !dismissedVideos.has(v.youtube_video_id));
const isEmpty = tab === "channels" ? channels.length === 0 : visibleVideos.length === 0;
const isLoading = tab === "channels" ? loadingChannels : loadingVideos;
const hasNextChannelPage = channels.length === PAGE_SIZE;
const hasNextVideoPage = videos.length === PAGE_SIZE;
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
<button
onClick={() => refreshMut.mutate()}
disabled={refreshMut.isPending}
className="flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
{refreshMut.isPending && (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
)}
{refreshMut.isPending ? "Searching…" : "Find more"}
</button>
</div>
{refreshMut.isSuccess && !refreshMut.isPending && (
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-300">
Searching YouTube for new channels results will appear in a few seconds.
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-zinc-800">
<Tab active={tab === "channels"} onClick={() => setTab("channels")} count={0}>
Channels
</Tab>
<Tab active={tab === "videos"} onClick={() => setTab("videos")} count={0}>
Videos
</Tab>
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : isEmpty ? (
<div className="flex flex-col items-center gap-4 py-24 text-center">
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center">
<svg className="w-7 h-7 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<div>
<p className="text-zinc-300 font-medium">Nothing here yet</p>
<p className="text-zinc-500 text-sm mt-1 max-w-xs">
Follow a few channels first, then hit "Find more" to discover similar ones.
</p>
</div>
<button
onClick={() => refreshMut.mutate()}
disabled={refreshMut.isPending}
className="mt-2 px-6 py-2.5 rounded-xl bg-accent text-black font-semibold text-sm hover:bg-yellow-300 transition-colors disabled:opacity-60"
>
{refreshMut.isPending ? "Searching…" : "Find channels"}
</button>
</div>
) : tab === "channels" ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{channels.map((item) => (
<ChannelCard key={item.id} item={item} />
))}
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setChannelPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
disabled={channelPage === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Prev
</button>
<span className="text-zinc-500 text-sm tabular-nums">Page {channelPage + 1}</span>
<button
onClick={() => { setChannelPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
disabled={!hasNextChannelPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{visibleVideos.map((v) => (
<VideoCard
key={v.youtube_video_id}
video={{ ...v, is_recommended: true }}
onDismiss={handleDismissVideo}
/>
))}
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setVideoPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
disabled={videoPage === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Prev
</button>
<span className="text-zinc-500 text-sm tabular-nums">Page {videoPage + 1}</span>
<button
onClick={() => { setVideoPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
disabled={!hasNextVideoPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,311 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload } from "../api";
import SortPicker from "../components/SortPicker";
const HISTORY_SORTS = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
{ value: "title", label: "Title AZ" },
{ value: "status", label: "Status" },
];
function sortHistory(items, sort) {
const arr = [...items];
if (sort === "oldest") return arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
if (sort === "title") return arr.sort((a, b) => (a.video_title ?? "").localeCompare(b.video_title ?? ""));
if (sort === "status") return arr.sort((a, b) => a.status.localeCompare(b.status));
return arr.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
const STATUS_COLORS = {
pending: "text-zinc-400",
downloading: "text-accent",
complete: "text-green-400",
failed: "text-red-400",
};
const STATUS_LABELS = {
pending: "Pending",
downloading: "Downloading",
complete: "Complete",
failed: "Failed",
};
function ProgressBar({ pct }) {
return (
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden mt-1.5">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
);
}
function daysRemaining(pendingDeleteAt) {
const diff = new Date(pendingDeleteAt) - Date.now();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
return Math.max(0, days);
}
export default function DownloadsPage() {
const [historySort, setHistorySort] = useState("newest");
const [confirmClear, setConfirmClear] = useState(false);
const qc = useQueryClient();
const { data: downloads, isLoading } = useQuery({
queryKey: ["downloads"],
queryFn: () => getDownloads().then((r) => r.data),
refetchInterval: (query) => {
const active = query.state.data?.some(
(d) => d.status === "pending" || d.status === "downloading"
);
return active ? 2000 : false;
},
});
const clearAllMut = useMutation({
mutationFn: deleteAllDownloads,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] });
setConfirmClear(false);
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
const active = downloads?.filter((d) => ["pending", "downloading"].includes(d.status)) ?? [];
const trash = downloads?.filter((d) => d.pending_delete_at) ?? [];
const history = useMemo(
() => sortHistory(
downloads?.filter((d) => !["pending", "downloading"].includes(d.status) && !d.pending_delete_at) ?? [],
historySort,
),
[downloads, historySort],
);
const hasRemovable = history.length > 0 || trash.length > 0;
return (
<div className="flex flex-col gap-8">
<div className="flex items-center justify-between">
<h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1>
{hasRemovable && (
confirmClear ? (
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-400">Remove all history and trash?</span>
<button
onClick={() => clearAllMut.mutate()}
disabled={clearAllMut.isPending}
className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-500 disabled:opacity-50 transition-colors"
>
Yes, remove all
</button>
<button
onClick={() => setConfirmClear(false)}
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmClear(true)}
className="text-sm text-zinc-500 hover:text-red-400 transition-colors"
>
Remove all
</button>
)
)}
</div>
{active.length > 0 && (
<section>
<h2 className="font-display font-semibold text-base text-zinc-400 mb-3">Active</h2>
<div className="flex flex-col gap-3">
{active.map((d) => (
<DownloadRow key={d.id} download={d} />
))}
</div>
</section>
)}
{trash.length > 0 && (
<section>
<h2 className="font-display font-semibold text-base text-zinc-400 mb-1">Trash</h2>
<p className="text-xs text-zinc-600 mb-3">Watched downloads auto-deleted when the timer runs out.</p>
<div className="flex flex-col gap-2">
{trash.map((d) => (
<TrashRow key={d.id} download={d} />
))}
</div>
</section>
)}
{history.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display font-semibold text-base text-zinc-400">History</h2>
<SortPicker value={historySort} onChange={setHistorySort} options={HISTORY_SORTS} />
</div>
<div className="flex flex-col gap-2">
{history.map((d) => (
<DownloadRow key={d.id} download={d} />
))}
</div>
</section>
)}
{!downloads?.length && (
<p className="text-zinc-500 text-sm">No downloads yet. Find a video and hit Download.</p>
)}
</div>
);
}
function TrashRow({ download: d }) {
const navigate = useNavigate();
const qc = useQueryClient();
const restoreMut = useMutation({
mutationFn: () => restoreDownload(d.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads"] }),
});
const deleteMut = useMutation({
mutationFn: () => deleteDownload(d.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["video-play", d.youtube_video_id] });
},
});
const days = daysRemaining(d.pending_delete_at);
const urgentColor = days <= 1 ? "text-red-400" : days <= 3 ? "text-amber-400" : "text-zinc-500";
return (
<div className="bg-zinc-900/60 border border-zinc-800 rounded-xl p-4 flex items-start gap-4">
<div
className="cursor-pointer shrink-0"
onClick={() => navigate(`/watch/${d.youtube_video_id}`)}
>
{d.video_thumbnail_url ? (
<img src={d.video_thumbnail_url} alt="" className="w-20 h-11 object-cover rounded-lg opacity-60" />
) : (
<div className="w-20 h-11 rounded-lg bg-zinc-800" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className="text-sm font-medium text-zinc-400 truncate cursor-pointer hover:text-zinc-200 transition-colors"
onClick={() => navigate(`/watch/${d.youtube_video_id}`)}
>
{d.video_title}
</p>
<p className={`text-xs mt-1 ${urgentColor}`}>
{days === 0 ? "Deletes today" : `Deletes in ${days} day${days !== 1 ? "s" : ""}`}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => restoreMut.mutate()}
disabled={restoreMut.isPending}
title="Keep this file"
className="px-2.5 py-1.5 rounded-lg text-xs font-medium text-zinc-300 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-40 transition-colors"
>
Keep
</button>
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
title="Delete now"
className="p-1.5 rounded-lg text-zinc-600 hover:text-red-400 hover:bg-red-900/20 disabled:opacity-40 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
);
}
function DownloadRow({ download: d }) {
const navigate = useNavigate();
const qc = useQueryClient();
const deleteMut = useMutation({
mutationFn: () => deleteDownload(d.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["video-play", d.youtube_video_id] });
},
});
const isActive = d.status === "pending" || d.status === "downloading";
const canWatch = d.status === "complete" && d.youtube_video_id;
return (
<div className="bg-zinc-900 rounded-xl p-4 flex items-start gap-4">
<div
className={canWatch ? "cursor-pointer shrink-0" : "shrink-0"}
onClick={canWatch ? () => navigate(`/watch/${d.youtube_video_id}`) : undefined}
>
{d.video_thumbnail_url ? (
<img
src={d.video_thumbnail_url}
alt=""
className="w-20 h-11 object-cover rounded-lg"
/>
) : (
<div className="w-20 h-11 rounded-lg bg-zinc-800" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium text-zinc-100 truncate ${canWatch ? "cursor-pointer hover:text-white" : ""}`}
onClick={canWatch ? () => navigate(`/watch/${d.youtube_video_id}`) : undefined}
>
{d.video_title}
</p>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs font-medium ${STATUS_COLORS[d.status]}`}>
{STATUS_LABELS[d.status]}
</span>
{d.status === "downloading" && (
<span className="text-xs text-zinc-500">{d.progress_percent.toFixed(0)}%</span>
)}
</div>
{d.status === "downloading" && <ProgressBar pct={d.progress_percent} />}
{d.error_message && (
<p className="text-xs text-red-400 mt-1 truncate">{d.error_message}</p>
)}
</div>
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
title={isActive ? "Cancel download" : "Delete file and record"}
className="shrink-0 p-1.5 rounded-lg text-zinc-600 hover:text-red-400 hover:bg-red-900/20 transition-colors disabled:opacity-40"
>
{isActive ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)}
</button>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getHistory } from "../api";
import VideoCard from "../components/VideoCard";
const PAGE_SIZE = 25;
export default function History() {
const [page, setPage] = useState(0);
const { data: videos = [], isLoading } = useQuery({
queryKey: ["history", page],
queryFn: () => getHistory(page, PAGE_SIZE).then(r => r.data),
staleTime: 60_000,
placeholderData: (prev) => prev,
});
const hasNext = videos.length === PAGE_SIZE;
return (
<div className="flex flex-col gap-6 max-w-screen-xl mx-auto">
<div className="flex items-center justify-between">
<h1 className="font-display font-bold text-2xl text-white">Watch History</h1>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : videos.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-20 text-center">
<p className="text-zinc-400 text-sm">No watch history yet. Start watching some videos!</p>
</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{videos.map((v) => (
<VideoCard key={v.youtube_video_id} video={v} />
))}
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
disabled={page === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Prev
</button>
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
<button
onClick={() => { setPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
disabled={!hasNext}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</>
)}
</div>
);
}

318
frontend/src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,318 @@
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { homeFeed, surpriseMe, getSettings, updateSettings, getChannels, markChannelsSeen } from "../api";
import VideoCard from "../components/VideoCard";
const PAGE_SIZE = 25;
const FEED_MODES = [
{ value: "ranked", label: "For you", hint: "Ranked by your taste" },
{ value: "chronological", label: "New", hint: "Everything in date order" },
{ value: "random", label: "Explore", hint: "Random from discovery pool" },
{ value: "inbox", label: "Inbox", hint: "New from followed channels since last visit" },
];
export default function Home() {
const qc = useQueryClient();
const [surpriseResults, setSurpriseResults] = useState(null);
const [mode, setMode] = useState(() => localStorage.getItem("home-feed-mode") ?? "ranked");
const [page, setPage] = useState(0);
const [dismissed, setDismissed] = useState(new Set());
const [shuffleKey, setShuffleKey] = useState(0);
const [duration, setDuration] = useState("");
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "grid");
const toggleViewMode = () => {
const next = viewMode === "grid" ? "list" : "grid";
localStorage.setItem("home-view-mode", next);
setViewMode(next);
};
const { data: userSettings } = useQuery({
queryKey: ["settings"],
queryFn: () => getSettings().then(r => r.data),
staleTime: 60_000,
});
const { data: channels = [] } = useQuery({
queryKey: ["channels"],
queryFn: () => getChannels().then(r => r.data),
staleTime: 60_000,
});
const inboxCount = channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
const markSeenMut = useMutation({
mutationFn: () => markChannelsSeen(),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const hideWatched = userSettings?.hide_watched_from_feed ?? false;
const handleHideWatchedToggle = () => {
const next = !hideWatched;
updateSettings({ hide_watched_from_feed: next });
qc.setQueryData(["settings"], old => old ? { ...old, hide_watched_from_feed: next } : old);
};
const { data: feedData = [], isLoading: loadingFeed } = useQuery({
queryKey: ["home-feed", mode, page, hideWatched, duration, mode === "random" ? shuffleKey : 0],
queryFn: () => homeFeed(page, PAGE_SIZE, mode, duration).then((r) => r.data),
staleTime: 10 * 60_000,
placeholderData: (prev) => prev,
});
const surpriseMut = useMutation({
mutationFn: () => surpriseMe().then((r) => r.data),
onSuccess: (data) => setSurpriseResults(data),
});
const visibleFeed = useMemo(
() => feedData.filter(v => !dismissed.has(v.youtube_video_id)),
[feedData, dismissed],
);
const hasFollowing = channels.length > 0 || feedData.length > 0 || page > 0;
const hasNextPage = mode === "ranked"
? feedData.filter(v => !v.is_recommended).length === PAGE_SIZE
: feedData.length === PAGE_SIZE;
const handleDismiss = (video) =>
setDismissed(prev => new Set([...prev, video.youtube_video_id]));
const handleModeChange = (newMode) => {
localStorage.setItem("home-feed-mode", newMode);
setMode(newMode);
setPage(0);
setDismissed(new Set());
};
const handleDurationChange = (d) => {
setDuration(prev => prev === d ? "" : d);
setPage(0);
setDismissed(new Set());
};
const handleReshuffle = () => {
setShuffleKey(k => k + 1);
setPage(0);
setDismissed(new Set());
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<div className="flex flex-col gap-10">
{loadingFeed ? (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : hasFollowing ? (
<section className="flex flex-col gap-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="font-display font-semibold text-xl text-zinc-200">Home</h2>
<div className="flex items-center gap-2">
<button
onClick={toggleViewMode}
title={viewMode === "grid" ? "Switch to list view" : "Switch to grid view"}
className="flex items-center justify-center w-8 h-8 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors border border-zinc-800"
>
{viewMode === "grid" ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
)}
</button>
<button
onClick={handleHideWatchedToggle}
title={hideWatched ? "Showing unwatched only" : "Showing all videos"}
className={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border",
hideWatched
? "bg-accent/10 text-accent border-accent/30"
: "text-zinc-500 border-zinc-800 hover:text-zinc-300 hover:border-zinc-700",
].join(" ")}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{hideWatched ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
)}
</svg>
{hideWatched ? "Unwatched" : "All"}
</button>
<div className="flex items-center gap-1 bg-zinc-900 rounded-xl p-1">
{FEED_MODES.map(m => (
<button
key={m.value}
onClick={() => handleModeChange(m.value)}
title={m.hint}
className={[
"relative px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
mode === m.value
? "bg-zinc-700 text-zinc-100"
: "text-zinc-500 hover:text-zinc-300",
].join(" ")}
>
{m.label}
{m.value === "inbox" && inboxCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
{inboxCount > 99 ? "99+" : inboxCount}
</span>
)}
</button>
))}
</div>
</div>
</div>
{/* Duration filter */}
<div className="flex items-center gap-1.5 -mt-3">
{[["short", "< 10 min"], ["medium", "1030 min"], ["long", "30+ min"]].map(([val, label]) => (
<button
key={val}
onClick={() => handleDurationChange(val)}
className={[
"px-2.5 py-1 rounded-full text-xs font-medium transition-colors",
duration === val
? "bg-zinc-700 text-zinc-100"
: "text-zinc-600 hover:text-zinc-400",
].join(" ")}
>
{label}
</button>
))}
</div>
{mode === "inbox" && (
<div className="flex items-center justify-between -mt-3">
<p className="text-xs text-zinc-600">Unwatched videos from followed channels since your last visit.</p>
<button
onClick={() => markSeenMut.mutate()}
disabled={markSeenMut.isPending}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4 disabled:opacity-50"
>
Mark all read
</button>
</div>
)}
{mode === "chronological" && (
<p className="text-xs text-zinc-600 -mt-3">All videos from channels you follow, newest first.</p>
)}
{mode === "random" && (
<div className="flex items-center justify-between -mt-3">
<p className="text-xs text-zinc-600">Random from your discovery pool no weighting, no ranking.</p>
<button
onClick={handleReshuffle}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Reshuffle
</button>
</div>
)}
{visibleFeed.length === 0 && !loadingFeed ? (
<p className="text-center text-zinc-500 text-sm py-12">
{mode === "inbox" ? "You're all caught up — no new videos since your last visit." : "Nothing to show here."}
</p>
) : (
<div className={viewMode === "list" ? "flex flex-col gap-2" : "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"}>
{visibleFeed.map((v) => (
<VideoCard
key={v.youtube_video_id}
video={v}
variant={viewMode}
onDismiss={v.is_recommended ? handleDismiss : undefined}
/>
))}
</div>
)}
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
disabled={page === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Prev
</button>
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
<button
onClick={() => { setPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
disabled={!hasNextPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</section>
) : (
<section className="flex flex-col items-center py-16 gap-4 text-center">
<p className="text-zinc-400 text-sm max-w-sm">
Follow some channels to get a personalised feed. Or let us pick something.
</p>
<button
onClick={() => surpriseMut.mutate()}
disabled={surpriseMut.isPending}
className="bg-accent text-black font-display font-bold text-lg px-8 py-4 rounded-2xl hover:scale-105 active:scale-95 transition-all disabled:opacity-60 shadow-lg shadow-accent/20"
>
<span className="flex items-center gap-2">
<span className="text-2xl"></span>
{surpriseMut.isPending ? "Picking something…" : "Surprise Me"}
</span>
</button>
{surpriseResults && (
<div className="mt-6 w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{surpriseResults.map((v) => (
<VideoCard key={v.youtube_video_id || v.video_id} video={{
youtube_video_id: v.youtube_video_id,
title: v.title,
thumbnail_url: v.thumbnail_url,
duration_seconds: v.duration_seconds,
channel_name: v.channel_name,
is_downloaded: v.downloaded,
is_watched: v.watched,
}} />
))}
</div>
)}
</section>
)}
{/* Surprise Me — footer */}
{hasFollowing && (
<section className="flex flex-col items-center gap-3 py-4 border-t border-zinc-800/60">
<p className="text-zinc-600 text-xs">Want something random?</p>
<button
onClick={() => surpriseMut.mutate()}
disabled={surpriseMut.isPending}
className="flex items-center gap-2 px-5 py-2 rounded-full bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
<span></span>
{surpriseMut.isPending ? "Picking…" : "Surprise Me"}
</button>
{surpriseResults && (
<div className="mt-4 w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{surpriseResults.map((v) => (
<VideoCard key={v.youtube_video_id || v.video_id} video={{
youtube_video_id: v.youtube_video_id,
title: v.title,
thumbnail_url: v.thumbnail_url,
duration_seconds: v.duration_seconds,
channel_name: v.channel_name,
is_downloaded: v.downloaded,
is_watched: v.watched,
}} />
))}
</div>
)}
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { useState, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getLikedVideos, refreshDiscovery } from "../api";
import VideoCard from "../components/VideoCard";
import SortPicker from "../components/SortPicker";
const LIKED_SORTS = [
{ value: "liked_at", label: "Liked recently" },
{ value: "newest", label: "Published newest" },
{ value: "channel", label: "Channel AZ" },
{ value: "title", label: "Title AZ" },
];
function sortLiked(items, sort) {
const arr = [...items];
if (sort === "newest") return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0));
if (sort === "channel") return arr.sort((a, b) => (a.channel_name ?? "").localeCompare(b.channel_name ?? ""));
if (sort === "title") return arr.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
return arr; // liked_at = backend order
}
export default function LikedPage() {
const qc = useQueryClient();
const [sort, setSort] = useState("liked_at");
const { data: videos = [], isLoading } = useQuery({
queryKey: ["liked-videos"],
queryFn: () => getLikedVideos().then((r) => r.data),
});
const refreshMut = useMutation({
mutationFn: refreshDiscovery,
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["discovery"] }), 3000),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="flex flex-col gap-8">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="font-display font-bold text-2xl text-zinc-100">Liked videos</h1>
<p className="text-sm text-zinc-500 mt-1">
{videos.length} video{videos.length !== 1 ? "s" : ""} · used to power discovery
</p>
</div>
<SortPicker value={sort} onChange={setSort} options={LIKED_SORTS} />
<button
onClick={() => refreshMut.mutate()}
disabled={refreshMut.isPending || refreshMut.isSuccess}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
{refreshMut.isPending ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
Running
</>
) : refreshMut.isSuccess ? "Discovery updated ✓" : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Refresh discovery
</>
)}
</button>
</div>
{videos.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-20 text-center">
<svg className="w-12 h-12 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<p className="text-zinc-400 font-medium">No liked videos yet</p>
<p className="text-zinc-600 text-sm max-w-xs">
Hit the heart on any video. Liked videos teach the discovery engine what you enjoy.
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{sortLiked(videos, sort).map((v) => (
<VideoCard key={v.youtube_video_id} video={v} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export default function LoginPage() {
const { login, register } = useAuth();
const navigate = useNavigate();
const [mode, setMode] = useState("login");
const [form, setForm] = useState({ username: "", email: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
if (mode === "login") {
await login(form.username, form.password);
} else {
await register(form.username, form.email, form.password);
}
navigate("/");
} catch (err) {
setError(err.response?.data?.detail || "Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-950 px-4">
<div className="w-full max-w-sm">
<h1 className="font-display font-bold text-3xl text-accent mb-2 text-center">YT Hub</h1>
<p className="text-zinc-500 text-sm text-center mb-8">Your personal YouTube command center</p>
<div className="bg-zinc-900 rounded-2xl p-6 border border-zinc-800">
<div className="flex gap-1 mb-6 bg-zinc-800 rounded-lg p-1">
{["login", "register"].map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`flex-1 py-1.5 text-sm font-medium rounded-md capitalize transition-colors ${
mode === m ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
}`}
>
{m}
</button>
))}
</div>
<form onSubmit={submit} className="flex flex-col gap-3">
<input
type="text"
placeholder="Username"
value={form.username}
onChange={set("username")}
required
className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-zinc-500 transition-colors"
/>
{mode === "register" && (
<input
type="email"
placeholder="Email"
value={form.email}
onChange={set("email")}
required
className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-zinc-500 transition-colors"
/>
)}
<input
type="password"
placeholder="Password"
value={form.password}
onChange={set("password")}
required
className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-zinc-500 transition-colors"
/>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={loading}
className="mt-2 bg-accent text-black font-semibold py-2.5 rounded-lg hover:bg-accent-light transition-colors disabled:opacity-60"
>
{loading ? "…" : mode === "login" ? "Sign in" : "Create account"}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getQueue, toggleQueue } from "../api";
import VideoCard from "../components/VideoCard";
export default function QueuePage() {
const qc = useQueryClient();
const { data: videos = [], isLoading } = useQuery({
queryKey: ["queue"],
queryFn: () => getQueue().then((r) => r.data),
staleTime: 30_000,
});
return (
<div className="flex flex-col gap-6">
<h1 className="font-display font-bold text-2xl text-zinc-100">Watch Later</h1>
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : videos.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-24 text-center">
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center">
<svg className="w-7 h-7 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 6h16M4 11h16M4 16h10m6-1l-4 2.5 4 2.5V15z" />
</svg>
</div>
<div>
<p className="text-zinc-300 font-medium">Queue is empty</p>
<p className="text-zinc-500 text-sm mt-1">
Hit the queue icon on any video to save it for later.
</p>
</div>
</div>
) : (
<>
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{videos.map((v) => (
<VideoCard
key={v.youtube_video_id}
video={v}
onRemoveFromQueue={() => {
toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] }));
}}
/>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { search } from "../api";
import VideoCard from "../components/VideoCard";
import ChannelCard from "../components/ChannelCard";
function Badge({ label }) {
return (
<span className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">{label}</span>
);
}
function Spinner() {
return (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
const PAGE_SIZE = 20;
export default function SearchResults() {
const [params] = useSearchParams();
const q = params.get("q") || "";
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
// Reset pagination when query changes
useEffect(() => { setVisibleCount(PAGE_SIZE); }, [q]);
// Local search — fast, appears immediately
const localQuery = useQuery({
queryKey: ["search", q, false],
queryFn: () => search(q, false).then((r) => r.data),
enabled: q.length > 0,
staleTime: 30_000,
});
// Live search — always runs in parallel, merges YouTube results in when ready
const liveQuery = useQuery({
queryKey: ["search", q, true],
queryFn: () => search(q, true).then((r) => r.data),
enabled: q.length > 0,
staleTime: 60_000,
});
// Show live data when available (superset of local), fall back to local while waiting
const data = liveQuery.data ?? localQuery.data;
const isLoading = localQuery.isLoading;
const isLiveLoading = liveQuery.isLoading && !liveQuery.isError;
const source = data?.source;
if (!q) {
return <p className="text-zinc-500 text-sm">Type something in the search bar above.</p>;
}
if (isLoading) return <Spinner />;
const videos = data?.videos ?? [];
const allChannels = data?.channels ?? [];
// Cap channels shown — when they're synthesized from 40 video results it's too many
const channels = allChannels.slice(0, 6);
const visibleVideos = videos.slice(0, visibleCount);
const hasMore = visibleCount < videos.length;
// Show channels first only when they're the primary result (few videos or FTS hit)
const channelsFirst = channels.length > 0 && (videos.length === 0 || source === "local");
return (
<div className="flex flex-col gap-8">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="font-display font-semibold text-xl text-zinc-100">
Results for <span className="text-accent">"{q}"</span>
</h1>
{source === "live" && <Badge label="Live from YouTube" />}
{source === "local" && <Badge label="Local library" />}
{source === "mixed" && <Badge label="Local + YouTube" />}
{isLiveLoading && (
<span className="flex items-center gap-1.5 text-xs text-zinc-500">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
searching YouTube
</span>
)}
</div>
{/* Channels first when they're the primary result */}
{channelsFirst && channels.length > 0 && (
<section>
<h2 className="font-display font-semibold text-xs text-zinc-400 uppercase tracking-wide mb-3">Channels</h2>
<div className="flex flex-col gap-2">
{channels.map((c) => (
<ChannelCard key={c.youtube_channel_id} channel={c} />
))}
</div>
</section>
)}
{/* Video results */}
{videos.length > 0 ? (
<section>
<h2 className="font-display font-semibold text-xs text-zinc-400 uppercase tracking-wide mb-3">
Videos
<span className="ml-2 text-zinc-600 font-normal normal-case">
{hasMore ? `${visibleCount} of ${videos.length}` : videos.length}
</span>
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{visibleVideos.map((v) => (
<VideoCard key={v.youtube_video_id} video={v} />
))}
</div>
{hasMore && (
<button
onClick={() => setVisibleCount((n) => n + PAGE_SIZE)}
className="mt-6 w-full py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
>
Show more ({videos.length - visibleCount} remaining)
</button>
)}
</section>
) : (
!isLoading && !channels.length && (
<p className="text-zinc-500 text-sm">No results found for "{q}".</p>
)
)}
{/* Channels after videos when videos dominate */}
{!channelsFirst && channels.length > 0 && (
<section>
<h2 className="font-display font-semibold text-xs text-zinc-400 uppercase tracking-wide mb-3">Channels</h2>
<div className="flex flex-col gap-2">
{channels.map((c) => (
<ChannelCard key={c.youtube_channel_id} channel={c} />
))}
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,433 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getSettings, updateSettings, exportData, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig } from "../api";
import { useAuth } from "../hooks/useAuth";
const REGION_OPTIONS = [
{ value: "US", label: "United States" },
{ value: "SE", label: "Sweden" },
{ value: "GB", label: "United Kingdom" },
{ value: "DE", label: "Germany" },
{ value: "JP", label: "Japan" },
{ value: "FR", label: "France" },
{ value: "CA", label: "Canada" },
{ value: "AU", label: "Australia" },
{ value: "BR", label: "Brazil" },
{ value: "IN", label: "India" },
{ value: "KR", label: "South Korea" },
{ value: "MX", label: "Mexico" },
];
const BROWSER_OPTIONS = [
{ value: "", label: "Disabled" },
{ value: "chrome", label: "Chrome" },
{ value: "chromium", label: "Chromium" },
{ value: "firefox", label: "Firefox" },
{ value: "brave", label: "Brave" },
{ value: "edge", label: "Edge" },
];
const QUALITY_OPTIONS = [
{ value: "best", label: "Highest available", hint: "Let yt-dlp pick the best it can get" },
{ value: "2160p", label: "4K — 2160p", hint: "Ultra HD, very large files" },
{ value: "1440p", label: "2K — 1440p", hint: "Quad HD" },
{ value: "1080p", label: "1080p — Full HD", hint: "Recommended for most people" },
{ value: "720p", label: "720p — HD", hint: "Good quality, smaller files" },
{ value: "480p", label: "480p — SD", hint: "Fast downloads, lower quality" },
{ value: "360p", label: "360p", hint: "Minimal bandwidth" },
{ value: "240p", label: "240p", hint: "Very low quality" },
{ value: "144p", label: "144p", hint: "Lowest quality" },
];
function Section({ title, children }) {
return (
<div className="flex flex-col gap-4">
<h2 className="font-display font-semibold text-base text-zinc-400 uppercase tracking-wide text-xs">
{title}
</h2>
<div className="bg-zinc-900 rounded-2xl divide-y divide-zinc-800">
{children}
</div>
</div>
);
}
function Row({ label, hint, children }) {
return (
<div className="flex items-center justify-between gap-6 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-medium text-zinc-200">{label}</p>
{hint && <p className="text-xs text-zinc-500 mt-0.5">{hint}</p>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function RegionPicker({ value, onChange }) {
const selected = new Set((value || "US,SE").split(",").map((r) => r.trim()).filter(Boolean));
const toggle = (code) => {
const next = new Set(selected);
if (next.has(code)) {
next.delete(code);
} else {
next.add(code);
}
if (next.size === 0) return; // always keep at least one
onChange([...next].join(","));
};
return (
<div className="flex flex-wrap gap-2">
{REGION_OPTIONS.map((r) => (
<button
key={r.value}
onClick={() => toggle(r.value)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
selected.has(r.value)
? "bg-accent text-black"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"
}`}
>
{r.label}
</button>
))}
</div>
);
}
function Toggle({ value, onChange }) {
return (
<button
onClick={() => onChange(!value)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
value ? "bg-accent" : "bg-zinc-700"
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`} />
</button>
);
}
function AdminSection() {
const qc = useQueryClient();
const [confirming, setConfirming] = useState(null);
const { data: users = [] } = useQuery({
queryKey: ["admin-users"],
queryFn: () => getAdminUsers().then(r => r.data),
});
const { data: adminConfig } = useQuery({
queryKey: ["admin-config"],
queryFn: () => getAdminConfig().then(r => r.data),
});
const deleteMut = useMutation({
mutationFn: (id) => deleteAdminUser(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin-users"] }); setConfirming(null); },
});
const configMut = useMutation({
mutationFn: (data) => updateAdminConfig(data),
onSuccess: (res) => qc.setQueryData(["admin-config"], res.data),
});
return (
<div className="flex flex-col gap-4">
<h2 className="font-display font-semibold text-base text-zinc-400 uppercase tracking-wide text-xs">Admin</h2>
<div className="bg-zinc-900 rounded-2xl divide-y divide-zinc-800">
<div className="flex items-center justify-between gap-6 px-5 py-4">
<div>
<p className="text-sm font-medium text-zinc-200">Open registration</p>
<p className="text-xs text-zinc-500 mt-0.5">Allow anyone with the URL to create an account.</p>
</div>
<Toggle
value={adminConfig?.allow_registration ?? true}
onChange={(v) => configMut.mutate({ allow_registration: v })}
/>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<p className="text-sm font-medium text-zinc-200">Users ({users.length})</p>
<div className="flex flex-col gap-1">
{users.map(u => (
<div key={u.id} className="flex items-center justify-between gap-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-zinc-200 truncate">{u.username}</span>
{u.is_admin && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/15 text-accent">admin</span>
)}
<span className="text-xs text-zinc-600 truncate">{u.email}</span>
</div>
{!u.is_admin && (
confirming === u.id ? (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => deleteMut.mutate(u.id)}
className="text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded-lg bg-red-950/40 transition-colors"
>
confirm delete
</button>
<button onClick={() => setConfirming(null)} className="text-xs text-zinc-500 hover:text-zinc-300">cancel</button>
</div>
) : (
<button
onClick={() => setConfirming(u.id)}
className="shrink-0 text-zinc-700 hover:text-red-400 transition-colors p-1"
title="Delete user"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default function SettingsPage() {
const { user } = useAuth();
const qc = useQueryClient();
const { data: s, isLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => getSettings().then((r) => r.data),
});
const mut = useMutation({
mutationFn: (patch) => updateSettings(patch),
onSuccess: (res) => qc.setQueryData(["settings"], res.data),
});
const set = (patch) => mut.mutate(patch);
if (isLoading) {
return (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="flex flex-col gap-8 max-w-2xl">
<h1 className="font-display font-bold text-2xl text-zinc-100">Settings</h1>
{/* Discovery */}
<Section title="Discovery">
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<p className="text-sm font-medium text-zinc-200">Trending regions</p>
<p className="text-xs text-zinc-500 mt-0.5">
Pull trending content from these regions to find new channels.
</p>
</div>
<RegionPicker
value={s?.discovery_regions ?? "US,SE"}
onChange={(v) => set({ discovery_regions: v })}
/>
</div>
</Section>
{/* YouTube authentication */}
<Section title="YouTube authentication">
<Row
label="Browser cookies"
hint="Pass cookies from your browser to bypass bot detection. You must be signed in to YouTube in that browser."
>
<select
value={s?.cookies_browser ?? ""}
onChange={(e) => set({ cookies_browser: e.target.value })}
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent"
>
{BROWSER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Row>
</Section>
{/* Download quality */}
<Section title="Download quality">
<Row
label="Preferred quality"
hint="Applied to all new downloads. Existing files are unaffected."
>
<select
value={s?.preferred_quality ?? "best"}
onChange={(e) => set({ preferred_quality: e.target.value })}
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent"
>
{QUALITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Row>
<Row
label="Max concurrent downloads"
hint="How many videos download at the same time."
>
<div className="flex items-center gap-3">
<input
type="range" min={1} max={5} step={1}
value={s?.max_concurrent_downloads ?? 3}
onChange={(e) => set({ max_concurrent_downloads: Number(e.target.value) })}
className="w-28 accent-yellow-400"
/>
<span className="text-sm text-zinc-300 w-4 text-center tabular-nums">
{s?.max_concurrent_downloads ?? 3}
</span>
</div>
</Row>
<Row
label="Auto-download on sync"
hint="When syncing followed channels, automatically download new videos."
>
<Toggle
value={s?.auto_download_on_sync ?? false}
onChange={(v) => set({ auto_download_on_sync: v })}
/>
</Row>
</Section>
{/* Feed */}
<Section title="Feed">
<Row
label="Hide watched videos"
hint="Remove already-watched videos from your home feed."
>
<Toggle
value={s?.hide_watched_from_feed ?? false}
onChange={(v) => set({ hide_watched_from_feed: v })}
/>
</Row>
<Row
label="Calm mode"
hint="Hide recommendation badges, discovery labels, and subscriber counts across the app."
>
<Toggle
value={s?.calm_mode ?? false}
onChange={(v) => set({ calm_mode: v })}
/>
</Row>
<Row
label="Hide subscriber counts"
hint="Stop showing subscriber numbers on channel pages and cards."
>
<Toggle
value={s?.hide_subscriber_counts ?? false}
onChange={(v) => set({ hide_subscriber_counts: v })}
/>
</Row>
</Section>
{/* Feed tuning */}
<Section title="Feed tuning">
<div className="px-5 py-3 pb-1">
<p className="text-xs text-zinc-500">
Adjust how the ranked feed weighs each signal. 5 is the default; 0 disables that dimension entirely.
</p>
</div>
{[
{ key: "feed_weight_recency", label: "Recency", hint: "How much newer videos are boosted over older ones" },
{ key: "feed_weight_affinity", label: "Taste match", hint: "How much videos matching your watch/search habits are boosted" },
{ key: "feed_weight_channel", label: "Channel loyalty", hint: "How much channels you watch and like more are boosted" },
].map(({ key, label, hint }) => (
<Row key={key} label={label} hint={hint}>
<div className="flex items-center gap-3">
<span className="text-[10px] text-zinc-600 w-3 text-right tabular-nums">0</span>
<input
type="range" min={0} max={10} step={0.5}
value={s?.[key] ?? 5}
onChange={(e) => set({ [key]: Number(e.target.value) })}
className="w-28 accent-yellow-400"
/>
<span className="text-[10px] text-zinc-600 w-3 tabular-nums">10</span>
<span className="text-sm text-zinc-300 w-6 text-center tabular-nums font-mono">
{(s?.[key] ?? 5).toFixed(1).replace(".0", "")}
</span>
</div>
</Row>
))}
</Section>
{/* Watching */}
<Section title="Watching">
<Row
label="Theater mode"
hint="Expand the video player to full width by default on every video page."
>
<Toggle
value={s?.theater_mode ?? false}
onChange={(v) => set({ theater_mode: v })}
/>
</Row>
<Row
label="Autoplay next video"
hint="Automatically play the next channel video when the current one ends."
>
<Toggle
value={s?.autoplay_enabled ?? false}
onChange={(v) => set({ autoplay_enabled: v })}
/>
</Row>
<Row
label="Mark as watched at"
hint="How far through a video before it's considered watched."
>
<div className="flex items-center gap-3">
<input
type="range" min={50} max={100} step={5}
value={s?.mark_watched_at_percent ?? 90}
onChange={(e) => set({ mark_watched_at_percent: Number(e.target.value) })}
className="w-28 accent-yellow-400"
/>
<span className="text-sm text-zinc-300 w-8 text-center tabular-nums">
{s?.mark_watched_at_percent ?? 90}%
</span>
</div>
</Row>
</Section>
{/* Data */}
<Section title="Data">
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-zinc-200">Export my data</p>
<p className="text-xs text-zinc-500 mt-0.5">Download your watch history, ratings, likes, bookmarks, and queue as JSON.</p>
</div>
<button
onClick={async () => {
const res = await exportData();
const url = URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = `ythub-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}}
className="shrink-0 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
>
Export
</button>
</div>
</Section>
{user?.is_admin && <AdminSection />}
{mut.isPending && (
<p className="text-xs text-zinc-500 text-center">Saving</p>
)}
</div>
);
}

View File

@@ -0,0 +1,197 @@
import { useQuery } from "@tanstack/react-query";
import { getStats } from "../api";
import { Link } from "react-router-dom";
function fmt(seconds) {
if (!seconds) return "0m";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
function StatCard({ label, value, sub }) {
return (
<div className="bg-zinc-900 rounded-2xl p-4 flex flex-col gap-1">
<p className="text-[11px] text-zinc-500 uppercase tracking-wider font-medium">{label}</p>
<p className="text-2xl font-bold text-white font-mono leading-none">{value}</p>
{sub && <p className="text-xs text-zinc-600 mt-0.5">{sub}</p>}
</div>
);
}
export default function Stats() {
const { data, isLoading } = useQuery({
queryKey: ["stats"],
queryFn: () => getStats().then(r => r.data),
staleTime: 5 * 60_000,
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-24">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!data) return null;
const maxSeconds = Math.max(...(data.top_channels.map(c => c.watch_seconds || 0)), 1);
const today = new Date();
const days = Array.from({ length: 30 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() - (29 - i));
return d.toISOString().slice(0, 10);
});
const dailyMap = Object.fromEntries((data.daily || []).map(d => [d.date, d]));
const maxDayCount = Math.max(...days.map(d => dailyMap[d]?.count || 0), 1);
const maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1);
const topTags = (data.taste_profile || []).slice(0, 12);
const maxTagScore = Math.max(...topTags.map(t => t.score || 0), 1);
return (
<div className="flex flex-col gap-8 max-w-4xl mx-auto">
<h1 className="font-display font-bold text-2xl text-white">Stats</h1>
{/* Top numbers */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total watched" value={data.total_watched.toLocaleString()} sub={fmt(data.total_watch_seconds) + " total"} />
<StatCard label="This week" value={data.this_week.count} sub={fmt(data.this_week.seconds)} />
<StatCard label="This month" value={data.this_month.count} sub={fmt(data.this_month.seconds)} />
<StatCard label="Total liked" value={(data.total_liked || 0).toLocaleString()} sub="videos" />
</div>
{/* Engagement row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard
label="Avg completion"
value={`${data.avg_completion_percent ?? 0}%`}
sub="of videos you start"
/>
<StatCard
label="Finished"
value={(data.finished_count || 0).toLocaleString()}
sub="watched ≥90%"
/>
<StatCard
label="Bailed early"
value={(data.bailed_count || 0).toLocaleString()}
sub="left before 20%"
/>
<StatCard
label="Rewatched"
value={(data.rewatched_videos || 0).toLocaleString()}
sub={`${data.total_rewatches || 0} total rewatches`}
/>
</div>
{/* Activity chart */}
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Activity last 30 days</h2>
<div className="flex items-end gap-0.5 h-16">
{days.map(date => {
const entry = dailyMap[date];
const count = entry?.count || 0;
const pct = count / maxDayCount;
return (
<div
key={date}
title={`${date}: ${count} video${count !== 1 ? "s" : ""}`}
className="flex-1 rounded-sm transition-all"
style={{
height: count === 0 ? "2px" : `${Math.max(pct * 100, 6)}%`,
backgroundColor: count === 0 ? "#27272a" : `hsl(50,95%,${30 + pct * 30}%)`,
}}
/>
);
})}
</div>
<div className="flex justify-between text-[10px] text-zinc-600">
<span>30 days ago</span><span>Today</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Top channels */}
{data.top_channels.length > 0 && (
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Top channels by watch time</h2>
<div className="flex flex-col gap-2.5">
{data.top_channels.map(ch => {
const pct = (ch.watch_seconds || 0) / maxSeconds;
return (
<div key={ch.id} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm">
<Link to={`/channels/${ch.id}`} className="text-zinc-200 hover:text-white transition-colors truncate text-[13px]">{ch.name}</Link>
<span className="text-zinc-500 text-[11px] shrink-0 ml-2 font-mono">{ch.watch_count} · {fmt(ch.watch_seconds)}</span>
</div>
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-accent/60 rounded-full" style={{ width: `${pct * 100}%` }} />
</div>
</div>
);
})}
</div>
</div>
)}
{/* Top categories */}
{data.top_categories?.length > 0 && (
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Top categories</h2>
<div className="flex flex-col gap-2.5">
{data.top_categories.map(cat => {
const pct = cat.watch_count / maxCatCount;
const comp = cat.avg_completion ? Math.round(cat.avg_completion) : null;
return (
<div key={cat.category} className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-[13px] text-zinc-200 truncate">{cat.category}</span>
<span className="text-[11px] text-zinc-500 shrink-0 ml-2 font-mono">
{cat.watch_count}{comp !== null ? ` · ${comp}% avg` : ""}
</span>
</div>
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
<div className="h-full bg-indigo-500/60 rounded-full" style={{ width: `${pct * 100}%` }} />
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Taste profile */}
{topTags.length > 0 && (
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
<div className="flex items-baseline gap-2">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2>
<p className="text-[11px] text-zinc-600">built from your watches, likes and bookmarks</p>
</div>
<div className="flex flex-wrap gap-2">
{topTags.map(t => {
const intensity = t.score / maxTagScore;
return (
<span
key={t.tag}
title={`score: ${t.score.toFixed(1)}`}
className="px-3 py-1 rounded-full text-xs font-medium"
style={{
backgroundColor: `rgba(250,204,21,${0.08 + intensity * 0.18})`,
color: `hsl(50,95%,${55 + intensity * 20}%)`,
fontSize: `${11 + intensity * 4}px`,
}}
>
{t.tag}
</span>
);
})}
</div>
</div>
)}
</div>
);
}

1127
frontend/src/pages/Watch.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
darkMode: "class",
theme: {
extend: {
fontFamily: {
sans: ["DM Sans", "system-ui", "sans-serif"],
display: ["Space Grotesk", "system-ui", "sans-serif"],
},
colors: {
accent: {
DEFAULT: "#f5a623",
light: "#fbbf45",
dark: "#d4891a",
},
},
aspectRatio: {
video: "16 / 9",
},
},
},
plugins: [],
};

18
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
optimizeDeps: {
esbuildOptions: {
loader: { ".js": "jsx" },
},
},
server: {
port: 5173,
proxy: {
"/api": { target: "http://localhost:8000", changeOrigin: true },
"/files": { target: "http://localhost:8000", changeOrigin: true },
},
},
});