Add widget API endpoints for backstage dashboard integration

New GET /api/widget/recent — returns recent unwatched videos from followed
channels (title, channel, thumbnail, published_at, duration, direct URL).
New GET /api/widget/stats — unwatched count, new this week, channel count.

Both endpoints auth via X-Widget-Key header (WIDGET_API_KEY env var) so
external services can call without JWT token lifecycle management.
Targets the first admin user's data.

Also: pass WIDGET_API_KEY through docker-compose environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mattias Tall
2026-06-11 11:48:18 +02:00
parent 33e9472f17
commit 7814fc9718
4 changed files with 111 additions and 1 deletions

View File

@@ -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

View File

@@ -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)

107
backend/routers/widget.py Normal file
View File

@@ -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,
}

View File

@@ -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