waxwing/app/api/endpoints/plex.py
pyr0ball c81fa3a93a feat: add Plex integration, update knowledge API and config
- Add app/api/endpoints/plex.py with Plex webhook receiver (fail-closed on missing secret)
- Add app/services/plex/ with Plex API client and gardening content filter
- Wire plex router into routes.py at /plex prefix
- Add Plex + cf-video config vars to app/core/config.py and .env.example
- Rename /knowledge/ingest endpoint to /knowledge/glean (pipeline verb alignment)
- Update tests to use /knowledge/glean endpoint
- Add python-multipart to environment.yml for form/webhook body parsing
- Add dev/dev-stop/dev-logs commands to manage.sh (hot-reload without Docker)
- Fix frontend/tsconfig.node.json to extend tsconfig.json (not tsconfig.node.json)
- Add .dev-pids/ to .gitignore
- Add frontend/package-lock.json
2026-07-11 22:38:37 -07:00

248 lines
8.9 KiB
Python

"""Plex webhook receiver — triggers the Strahl → cf-video → knowledge pipeline.
Flow:
1. Plex fires library.new for a gardening show episode on Strahl
2. We filter to configured gardening sections (Garden Rescue, Gardener's World)
3. Background task downloads the file from Plex's local HTTP API
4. File is POSTed to cf-video's /caption/upload endpoint (Muninn via coordinator)
5. Caption events are stored as knowledge_facts (instruction type, one per event)
"""
from __future__ import annotations
import hmac
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, BackgroundTasks, Form, HTTPException, Query
from app.core.config import settings
from app.db.store import Store
from app.models.schemas.knowledge import KnowledgeIngestRequest, KnowledgeSourceSpec, SegmentKnowledge
from app.services.knowledge.ingest import ingest
from app.services.plex.client import PlexClient, PlexMediaItem, parse_webhook
logger = logging.getLogger(__name__)
router = APIRouter()
def _plex_client() -> PlexClient:
if not settings.PLEX_TOKEN:
raise HTTPException(503, detail="PLEX_TOKEN not configured")
return PlexClient(settings.PLEX_URL, settings.PLEX_TOKEN)
def _is_gardening_show(item: "PlexMediaItem") -> bool:
"""Return True if this media item is from a configured gardening show.
Plex libraries are named "Series" / "Movies" — the show titles (Garden Rescue,
Gardener's World) live as grandparentTitle inside the Series section.
We match against show_title (grandparentTitle) or title for movies.
"""
allowed = {s.strip().lower() for s in settings.PLEX_GARDENING_SECTIONS}
candidate = (item.show_title or item.title).strip().lower()
return candidate in allowed
async def _run_pipeline(item: PlexMediaItem) -> None:
"""Download video from Plex, caption it via cf-video, store facts."""
client = _plex_client()
suffix = Path(item.part_key).suffix or ".mkv"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp_path = tmp.name
try:
logger.info("Plex pipeline: downloading %r from %s", item.title, item.part_key)
await client.download_file(item.part_key, tmp_path)
caption_resp = await _caption_upload(tmp_path)
if caption_resp is None:
logger.error("Plex pipeline: caption failed for %r — skipping ingest", item.title)
return
facts = _events_to_facts(caption_resp, item)
if not facts:
logger.info("Plex pipeline: no events extracted from %r", item.title)
return
_glean(item, facts)
except Exception:
logger.exception("Plex pipeline failed for %r", item.title)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
async def _caption_upload(video_path: str) -> dict[str, Any] | None:
"""POST the video file to cf-video's /caption/upload endpoint.
Uses CF_VIDEO_URL directly (point at a pre-allocated cf-video service on Muninn).
"""
if not settings.CF_VIDEO_URL:
logger.error("CF_VIDEO_URL not set — cannot send video to cf-video")
return None
url = f"{settings.CF_VIDEO_URL.rstrip('/')}/caption/upload"
try:
async with httpx.AsyncClient(timeout=600.0) as http:
with open(video_path, "rb") as fh:
resp = await http.post(url, files={"file": fh})
resp.raise_for_status()
return resp.json()
except httpx.HTTPError as exc:
logger.error("cf-video upload failed: %s", exc)
return None
def _events_to_facts(caption_resp: dict[str, Any], item: PlexMediaItem) -> list[dict]:
"""Map VideoEvent list → SegmentKnowledge dicts (instruction type, one per event).
Each event becomes one 'instruction' fact. A richer LLM extraction pass can
replace or supplement this later by reclassifying facts by type.
"""
events: list[dict] = caption_resp.get("events", [])
facts = []
for ev in events:
desc = (ev.get("description") or "").strip()
if not desc:
continue
facts.append({
"fact_type": "instruction",
"confidence": 0.7,
"payload": {
"action": desc,
"subject": item.show_title or item.title,
"timestamp_start_ms": ev.get("start"),
"timestamp_end_ms": ev.get("end"),
"source_episode": item.title,
},
})
# Also store the overall scene description as a single instruction if no events.
if not facts and caption_resp.get("caption"):
facts.append({
"fact_type": "instruction",
"confidence": 0.5,
"payload": {
"action": caption_resp["caption"],
"subject": item.show_title or item.title,
"source_episode": item.title,
},
})
return facts
def _glean(item: PlexMediaItem, facts: list[dict]) -> None:
"""Write extracted facts directly to the knowledge store (same process, no HTTP round-trip)."""
source = KnowledgeSourceSpec(
video_path=item.part_key,
presenter=None,
location=None,
source_domain="gardening",
)
knowledge_facts = [
SegmentKnowledge(
fact_type=f["fact_type"],
confidence=f.get("confidence", 1.0),
payload=f["payload"],
)
for f in facts
]
request = KnowledgeIngestRequest(source=source, facts=knowledge_facts)
store = Store(settings.DB_PATH)
try:
result = ingest(store.conn, request)
logger.info(
"Plex pipeline: gleaned %d facts (%d inserted, %d dupes) for %r",
result.facts_received,
result.facts_inserted,
result.facts_skipped_duplicate,
item.title,
)
finally:
store.close()
# ── Endpoints ──────────────────────────────────────────────────────────────────
def _verify_webhook_secret(token: str | None) -> None:
"""Reject requests that don't carry the configured PLEX_WEBHOOK_SECRET.
Uses hmac.compare_digest to prevent timing-based secret enumeration.
If PLEX_WEBHOOK_SECRET is not set in the environment the endpoint is
disabled entirely — misconfiguration should fail closed, not open.
"""
expected = settings.PLEX_WEBHOOK_SECRET
if not expected:
raise HTTPException(503, detail="PLEX_WEBHOOK_SECRET not configured")
if not token or not hmac.compare_digest(token, expected):
raise HTTPException(401, detail="Invalid webhook token")
@router.post("/webhook")
async def plex_webhook(
background_tasks: BackgroundTasks,
payload: str = Form(...),
token: str | None = Query(None, alias="token"),
) -> dict[str, str]:
"""Receive Plex webhook (multipart/form-data with 'payload' JSON field).
Requires ?token=<PLEX_WEBHOOK_SECRET> in the URL — configure this in Plex's
Webhooks settings: http://<host>:8522/api/v1/plex/webhook?token=<secret>
Plex fires this for library.new, media.play, and many other events.
We ack immediately and dispatch the pipeline as a background task.
"""
_verify_webhook_secret(token)
item = parse_webhook(payload)
if item is None:
# Not an event we care about — Plex fires many events, this is normal.
return {"status": "ignored"}
if not _is_gardening_show(item):
logger.debug(
"Plex webhook: skipping %r (show %r not in PLEX_GARDENING_SECTIONS)",
item.title, item.show_title,
)
return {"status": "ignored", "show": item.show_title or item.title}
logger.info(
"Plex webhook: queuing pipeline for %r (show=%r, event=%r)",
item.title, item.show_title, item.event,
)
background_tasks.add_task(_run_pipeline, item)
return {"status": "queued", "title": item.title, "show": item.show_title or item.title}
@router.get("/sections")
async def list_plex_sections() -> list[dict[str, Any]]:
"""List all Plex library sections on Strahl.
Note: PLEX_GARDENING_SECTIONS matches show titles (grandparentTitle), not
section names — Strahl uses generic section names like "Series" / "Movies".
"""
client = _plex_client()
try:
sections = await client.list_sections()
allowed = {s.strip().lower() for s in settings.PLEX_GARDENING_SECTIONS}
return [
{
"key": s.get("key"),
"title": s.get("title"),
"type": s.get("type"),
"note": "filter is by show title, not section name",
"configured_shows": list(allowed),
}
for s in sections
]
except httpx.HTTPError as exc:
raise HTTPException(502, detail=f"Plex unreachable: {exc}") from exc