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"
|
secret_key: str = "changeme-use-a-real-secret-in-production"
|
||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 * 24 * 7 # 1 week
|
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 .config import settings
|
||||||
from .database import init_db, SessionLocal
|
from .database import init_db, SessionLocal
|
||||||
from .services import ytdlp as ytdlp_service
|
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")
|
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(collections_router.router, prefix="/api/collections", tags=["collections"])
|
||||||
app.include_router(admin_router.router, prefix="/api/admin", tags=["admin"])
|
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(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)
|
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
|
DATABASE_URL: sqlite:////data/app.db
|
||||||
DOWNLOAD_PATH: /downloads
|
DOWNLOAD_PATH: /downloads
|
||||||
SECRET_KEY: ${SECRET_KEY:-changeme}
|
SECRET_KEY: ${SECRET_KEY:-changeme}
|
||||||
|
WIDGET_API_KEY: ${WIDGET_API_KEY:-}
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|||||||
Reference in New Issue
Block a user