Sync throttling: - sync-all now skips channels crawled within the last 6 hours (prevents re-scraping 1266 channels on every button press) - Channels are queued into a single _index_channels_batch task that runs with 1.5s delay between each yt-dlp call instead of firing 1266 background tasks simultaneously - Startup enrich task reduced from 10 to 3 videos (3 yt-dlp calls on each container restart) - Enrich task adds 2s sleep between metadata fetches SQLite stability: - busy_timeout=5000 prevents SQLITE_BUSY errors under concurrent load - synchronous=NORMAL speeds up writes without data loss risk (safe with WAL) Following page: - staleTime: 60s on channels query so cached data is reused immediately on revisit; gcTime keeps it in memory for 5 min Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
161 lines
7.8 KiB
Python
161 lines
7.8 KiB
Python
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 cookies_file TEXT DEFAULT ''",
|
|
"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",
|
|
"ALTER TABLE user_settings ADD COLUMN use_oauth2 INTEGER DEFAULT 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"))
|
|
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 "")
|
|
ytdlp_service.set_cookies_file(first_user_settings.cookies_file or "")
|
|
ytdlp_service.set_oauth2(bool(getattr(first_user_settings, "use_oauth2", False)))
|
|
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=(3,), daemon=True).start()
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok"}
|