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