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:
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
107
backend/routers/widget.py
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user