- 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
248 lines
8.9 KiB
Python
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
|