diff --git a/backend/config.py b/backend/config.py index 278053e..7ac4e27 100644 --- a/backend/config.py +++ b/backend/config.py @@ -9,6 +9,7 @@ class Settings(BaseSettings): secret_key: str = "changeme-use-a-real-secret-in-production" algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 24 * 7 # 1 week + widget_api_key: str = "" # set WIDGET_API_KEY in env for backstage integration diff --git a/backend/main.py b/backend/main.py index 610bc47..dbc5880 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,7 +6,7 @@ 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, playlists as playlists_router +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, playlists as playlists_router, widget as widget_router app = FastAPI(title="YouTube Hub", version="0.1.0") @@ -30,6 +30,7 @@ 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"]) app.include_router(playlists_router.router, prefix="/api/playlists", tags=["playlists"]) +app.include_router(widget_router.router, prefix="/api/widget", tags=["widget"]) os.makedirs(settings.download_path, exist_ok=True) diff --git a/backend/routers/widget.py b/backend/routers/widget.py new file mode 100644 index 0000000..8eae797 --- /dev/null +++ b/backend/routers/widget.py @@ -0,0 +1,107 @@ +""" +Read-only widget endpoints for external dashboards (e.g. backstage). +Auth: X-Widget-Key header must match WIDGET_API_KEY env var. +No user session required — returns data for the first admin user. +""" +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Header, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ..config import settings +from ..database import get_db +from ..models import User + +router = APIRouter() + + +def _require_widget_key(x_widget_key: Optional[str] = Header(default=None)): + if not settings.widget_api_key: + raise HTTPException(status_code=503, detail="widget API not configured") + if x_widget_key != settings.widget_api_key: + raise HTTPException(status_code=401, detail="invalid widget key") + + +def _get_widget_user(db: Session) -> User: + user = db.query(User).filter_by(is_admin=True).order_by(User.id).first() + if not user: + user = db.query(User).order_by(User.id).first() + if not user: + raise HTTPException(status_code=503, detail="no users") + return user + + +@router.get("/recent") +def recent_videos( + limit: int = 12, + db: Session = Depends(get_db), + _: None = Depends(_require_widget_key), +): + """Recent unwatched videos from followed channels.""" + user = _get_widget_user(db) + rows = db.execute( + text(""" + SELECT v.youtube_video_id, v.title, v.thumbnail_url, + v.duration_seconds, v.published_at, + c.name AS channel_name, c.youtube_channel_id AS channel_yt_id, + COALESCE(uv.watched, 0) AS watched + 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 user_videos uv ON v.id = uv.video_id AND uv.user_id = :uid + WHERE COALESCE(uv.watched, 0) = 0 + AND v.published_at IS NOT NULL + AND (uc.muted_until IS NULL OR datetime(uc.muted_until) < datetime('now')) + ORDER BY v.published_at DESC + LIMIT :limit + """), + {"uid": user.id, "limit": limit}, + ).mappings().all() + + return { + "videos": [ + { + "youtube_video_id": r["youtube_video_id"], + "title": r["title"], + "channel_name": r["channel_name"], + "thumbnail_url": r["thumbnail_url"], + "published_at": r["published_at"], + "duration_seconds": r["duration_seconds"], + "url": f"https://yt.nullinput.io/watch/{r['youtube_video_id']}", + } + for r in rows + ] + } + + +@router.get("/stats") +def widget_stats( + db: Session = Depends(get_db), + _: None = Depends(_require_widget_key), +): + """Quick stats: unwatched count, channel count, recent activity.""" + user = _get_widget_user(db) + row = db.execute( + text(""" + SELECT + COUNT(*) FILTER (WHERE COALESCE(uv.watched, 0) = 0) AS unwatched, + COUNT(*) FILTER (WHERE v.published_at >= datetime('now', '-7 days') + AND COALESCE(uv.watched, 0) = 0) AS new_this_week, + COUNT(DISTINCT uc.channel_id) AS channel_count + FROM user_channels uc + JOIN channels c ON c.id = uc.channel_id + JOIN videos v ON v.channel_id = c.id + LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :uid + WHERE uc.user_id = :uid AND uc.status = 'followed' + """), + {"uid": user.id}, + ).mappings().first() + + return { + "unwatched": row["unwatched"] if row else 0, + "new_this_week": row["new_this_week"] if row else 0, + "channel_count": row["channel_count"] if row else 0, + } diff --git a/docker-compose.yml b/docker-compose.yml index 0c8906c..96507a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: DATABASE_URL: sqlite:////data/app.db DOWNLOAD_PATH: /downloads SECRET_KEY: ${SECRET_KEY:-changeme} + WIDGET_API_KEY: ${WIDGET_API_KEY:-} frontend: build: context: ./frontend