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
This commit is contained in:
parent
5f5cb99db6
commit
c81fa3a93a
14 changed files with 2460 additions and 13 deletions
|
|
@ -17,6 +17,13 @@ COORDINATOR_URL=http://localhost:7700
|
||||||
# License key (required for Paid/Premium tier features)
|
# License key (required for Paid/Premium tier features)
|
||||||
# CF_LICENSE_KEY=CFG-WXWG-XXXX-XXXX-XXXX
|
# CF_LICENSE_KEY=CFG-WXWG-XXXX-XXXX-XXXX
|
||||||
|
|
||||||
|
# Plex integration — Strahl library → cf-video → knowledge pipeline
|
||||||
|
# PLEX_URL=http://10.1.10.117:32400
|
||||||
|
# PLEX_TOKEN=<your-plex-token>
|
||||||
|
# PLEX_WEBHOOK_SECRET=<random-secret> # append to webhook URL: ?token=<secret> — required, fails closed if unset
|
||||||
|
# PLEX_GARDENING_SECTIONS=Garden Rescue,Gardener's World
|
||||||
|
# CF_VIDEO_URL=http://127.0.0.1:8016 # direct to cf-video on Muninn (via SSH tunnel port forward)
|
||||||
|
|
||||||
# Cloud mode (optional — enables per-user data isolation)
|
# Cloud mode (optional — enables per-user data isolation)
|
||||||
CLOUD_MODE=false
|
CLOUD_MODE=false
|
||||||
# CLOUD_DATA_ROOT=/devl/waxwing-cloud-data
|
# CLOUD_DATA_ROOT=/devl/waxwing-cloud-data
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -34,3 +34,4 @@ frontend/dist/
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
|
.dev-pids/
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ def _get_store() -> Store:
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ingest", response_model=IngestResponse)
|
@router.post("/glean", response_model=IngestResponse)
|
||||||
async def ingest_knowledge(
|
async def ingest_knowledge(
|
||||||
body: KnowledgeIngestRequest, store: Store = Depends(_get_store)
|
body: KnowledgeIngestRequest, store: Store = Depends(_get_store)
|
||||||
):
|
):
|
||||||
|
|
|
||||||
248
app/api/endpoints/plex.py
Normal file
248
app/api/endpoints/plex.py
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
"""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
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.endpoints import health, plants, locations, grow_events, knowledge
|
from app.api.endpoints import health, plants, locations, grow_events, knowledge, plex
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
|
|
@ -8,3 +8,4 @@ api_router.include_router(plants.router, prefix="/plants", tags=["plants"])
|
||||||
api_router.include_router(locations.router, prefix="/locations", tags=["locations"])
|
api_router.include_router(locations.router, prefix="/locations", tags=["locations"])
|
||||||
api_router.include_router(grow_events.router, prefix="/grow-events", tags=["grow-events"])
|
api_router.include_router(grow_events.router, prefix="/grow-events", tags=["grow-events"])
|
||||||
api_router.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
|
api_router.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
|
||||||
|
api_router.include_router(plex.router, prefix="/plex", tags=["plex"])
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,22 @@ class Settings:
|
||||||
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
||||||
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
||||||
|
|
||||||
|
# Plex integration (Strahl → cf-video → knowledge pipeline)
|
||||||
|
PLEX_URL: str = os.environ.get("PLEX_URL", "http://10.1.10.117:32400")
|
||||||
|
PLEX_TOKEN: str | None = os.environ.get("PLEX_TOKEN")
|
||||||
|
# Shared secret appended to the Plex webhook URL as ?token=<secret>
|
||||||
|
# Required — endpoint returns 503 if unset (fail closed)
|
||||||
|
PLEX_WEBHOOK_SECRET: str | None = os.environ.get("PLEX_WEBHOOK_SECRET")
|
||||||
|
# Comma-separated show titles (grandparentTitle in Plex webhooks) — matched case-insensitively.
|
||||||
|
# Strahl uses generic section names ("Series", "Movies"); individual show titles are used here.
|
||||||
|
PLEX_GARDENING_SECTIONS: list[str] = [
|
||||||
|
s.strip()
|
||||||
|
for s in os.environ.get("PLEX_GARDENING_SECTIONS", "Garden Rescue,Gardener's World").split(",")
|
||||||
|
if s.strip()
|
||||||
|
]
|
||||||
|
# Direct URL to a running cf-video service (http://127.0.0.1:8016 on Muninn, or via tunnel)
|
||||||
|
CF_VIDEO_URL: str | None = os.environ.get("CF_VIDEO_URL")
|
||||||
|
|
||||||
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
||||||
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "false").lower() in ("1", "true", "yes")
|
CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "false").lower() in ("1", "true", "yes")
|
||||||
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/waxwing-cloud-data"))
|
CLOUD_DATA_ROOT: Path = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/waxwing-cloud-data"))
|
||||||
|
|
|
||||||
0
app/services/plex/__init__.py
Normal file
0
app/services/plex/__init__.py
Normal file
157
app/services/plex/client.py
Normal file
157
app/services/plex/client.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Plex Media Server HTTP client for Waxwing knowledge ingestion.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Parse Plex webhook payloads (multipart/form-data, single 'payload' field)
|
||||||
|
- Fetch media file bytes from Plex's local HTTP API
|
||||||
|
- List library sections so callers can identify gardening content
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Plex part keys are always /library/parts/<numeric id>/file — nothing else.
|
||||||
|
# Rejecting anything outside this pattern prevents SSRF via forged webhook payloads.
|
||||||
|
_PART_KEY_RE = re.compile(r"^/library/parts/\d+/file/?$")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Plex sends webhooks for many events; only these matter for knowledge ingestion.
|
||||||
|
INGEST_EVENTS: frozenset[str] = frozenset({"library.new", "media.play"})
|
||||||
|
|
||||||
|
# Library types that may contain gardening video content.
|
||||||
|
_VIDEO_TYPES: frozenset[str] = frozenset({"show", "movie"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlexMediaItem:
|
||||||
|
"""Minimal metadata extracted from a Plex webhook payload."""
|
||||||
|
event: str
|
||||||
|
rating_key: str # Plex item ID
|
||||||
|
part_key: str # path suffix for file download: /library/parts/{id}/file
|
||||||
|
title: str
|
||||||
|
show_title: str | None # None for movies
|
||||||
|
library_section: str
|
||||||
|
duration_ms: int | None
|
||||||
|
thumb: str | None # poster path, unused but useful for future UI
|
||||||
|
|
||||||
|
|
||||||
|
def parse_webhook(raw_payload: str) -> PlexMediaItem | None:
|
||||||
|
"""Parse the JSON string from Plex's multipart 'payload' field.
|
||||||
|
|
||||||
|
Returns None for events we don't act on (playback progress, rating, etc.).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data: dict[str, Any] = json.loads(raw_payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Plex webhook: invalid JSON payload")
|
||||||
|
return None
|
||||||
|
|
||||||
|
event = data.get("event", "")
|
||||||
|
if event not in INGEST_EVENTS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata: dict[str, Any] = data.get("Metadata", {})
|
||||||
|
media_type = metadata.get("type", "")
|
||||||
|
if media_type not in _VIDEO_TYPES:
|
||||||
|
logger.debug("Plex webhook: skipping non-video type %r", media_type)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Plex nests file parts under Media[0].Part[0]
|
||||||
|
parts: list[dict] = []
|
||||||
|
for media in metadata.get("Media", []):
|
||||||
|
parts.extend(media.get("Part", []))
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
logger.warning("Plex webhook: no media parts found for %r", metadata.get("title"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
part = parts[0]
|
||||||
|
part_key = part.get("key", "")
|
||||||
|
if not part_key:
|
||||||
|
logger.warning("Plex webhook: part has no key for %r", metadata.get("title"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
return PlexMediaItem(
|
||||||
|
event=event,
|
||||||
|
rating_key=str(metadata.get("ratingKey", "")),
|
||||||
|
part_key=part_key,
|
||||||
|
title=metadata.get("title", "Unknown"),
|
||||||
|
show_title=metadata.get("grandparentTitle") or metadata.get("parentTitle"),
|
||||||
|
library_section=metadata.get("librarySectionTitle", ""),
|
||||||
|
duration_ms=metadata.get("duration"),
|
||||||
|
thumb=metadata.get("thumb"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexClient:
|
||||||
|
"""Thin async client for Plex Media Server's local HTTP API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, token: str) -> None:
|
||||||
|
# base_url: e.g. "http://10.1.10.117:32400"
|
||||||
|
self._base = base_url.rstrip("/")
|
||||||
|
self._token = token
|
||||||
|
self._headers = {
|
||||||
|
"X-Plex-Token": token,
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_sections(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return all library sections (useful for discovering gardening libraries)."""
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/library/sections",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("MediaContainer", {}).get("Directory", [])
|
||||||
|
|
||||||
|
async def download_file(self, part_key: str, dest_path: str) -> int:
|
||||||
|
"""Stream a media file from Plex to dest_path on the local filesystem.
|
||||||
|
|
||||||
|
part_key comes from PlexMediaItem.part_key (e.g. /library/parts/12345/file).
|
||||||
|
Returns the number of bytes written.
|
||||||
|
|
||||||
|
Raises ValueError if part_key does not match the expected Plex path format —
|
||||||
|
this guards against SSRF via a forged webhook payload supplying an arbitrary URL.
|
||||||
|
"""
|
||||||
|
if not _PART_KEY_RE.match(part_key):
|
||||||
|
raise ValueError(f"Rejected unsafe part_key: {part_key!r}")
|
||||||
|
|
||||||
|
url = f"{self._base}{part_key}"
|
||||||
|
# Sanity-check: constructed URL must stay on the configured Plex host.
|
||||||
|
if urlparse(url).hostname != urlparse(self._base).hostname:
|
||||||
|
raise ValueError(f"part_key would redirect off Plex host: {url!r}")
|
||||||
|
|
||||||
|
params = {"X-Plex-Token": self._token}
|
||||||
|
written = 0
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=None) as client:
|
||||||
|
async with client.stream("GET", url, params=params) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(dest_path, "wb") as fh:
|
||||||
|
async for chunk in resp.aiter_bytes(chunk_size=1 << 20): # 1 MB
|
||||||
|
fh.write(chunk)
|
||||||
|
written += len(chunk)
|
||||||
|
|
||||||
|
logger.info("Plex download: %d bytes → %s", written, dest_path)
|
||||||
|
return written
|
||||||
|
|
||||||
|
async def get_item_metadata(self, rating_key: str) -> dict[str, Any]:
|
||||||
|
"""Fetch full metadata for a single library item."""
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/library/metadata/{rating_key}",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
items = data.get("MediaContainer", {}).get("Metadata", [])
|
||||||
|
return items[0] if items else {}
|
||||||
|
|
@ -10,6 +10,7 @@ dependencies:
|
||||||
- uvicorn[standard]>=0.27
|
- uvicorn[standard]>=0.27
|
||||||
- pydantic>=2.5
|
- pydantic>=2.5
|
||||||
- httpx>=0.27
|
- httpx>=0.27
|
||||||
|
- python-multipart>=0.0.9
|
||||||
- pyyaml>=6.0
|
- pyyaml>=6.0
|
||||||
- pytest>=8.0
|
- pytest>=8.0
|
||||||
- pytest-asyncio>=0.23
|
- pytest-asyncio>=0.23
|
||||||
|
|
|
||||||
1968
frontend/package-lock.json
generated
Normal file
1968
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.node.json",
|
"extends": "@vue/tsconfig/tsconfig.json",
|
||||||
"include": ["vite.config.ts"],
|
"include": ["vite.config.ts"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|
|
||||||
50
manage.sh
50
manage.sh
|
|
@ -9,9 +9,16 @@ COMPOSE_FILE="compose.yml"
|
||||||
OVERRIDE_FLAG=""
|
OVERRIDE_FLAG=""
|
||||||
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
|
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
|
||||||
|
|
||||||
|
DEV_DIR=".dev-pids"
|
||||||
|
DEV_API_PID="$DEV_DIR/api.pid"
|
||||||
|
DEV_WEB_PID="$DEV_DIR/web.pid"
|
||||||
|
DEV_API_LOG="$DEV_DIR/api.log"
|
||||||
|
DEV_WEB_LOG="$DEV_DIR/web.log"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test|update}"
|
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test|update|dev|dev-stop|dev-logs}"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Docker commands:"
|
||||||
echo " start Build (if needed) and start all services"
|
echo " start Build (if needed) and start all services"
|
||||||
echo " stop Stop and remove containers"
|
echo " stop Stop and remove containers"
|
||||||
echo " restart Stop then start"
|
echo " restart Stop then start"
|
||||||
|
|
@ -21,6 +28,11 @@ usage() {
|
||||||
echo " build Rebuild Docker images without cache"
|
echo " build Rebuild Docker images without cache"
|
||||||
echo " test Run pytest test suite"
|
echo " test Run pytest test suite"
|
||||||
echo " update git pull + rebuild + restart"
|
echo " update git pull + rebuild + restart"
|
||||||
|
echo ""
|
||||||
|
echo "Dev server commands (no Docker):"
|
||||||
|
echo " dev Start FastAPI + Vite dev server (hot reload)"
|
||||||
|
echo " dev-stop Stop dev servers"
|
||||||
|
echo " dev-logs Tail dev server logs"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +75,42 @@ case "$cmd" in
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
|
||||||
echo "Waxwing updated → http://localhost:${WEB_PORT}"
|
echo "Waxwing updated → http://localhost:${WEB_PORT}"
|
||||||
;;
|
;;
|
||||||
|
dev)
|
||||||
|
mkdir -p "$DEV_DIR"
|
||||||
|
if [[ -f "$DEV_API_PID" ]] && kill -0 "$(cat "$DEV_API_PID")" 2>/dev/null; then
|
||||||
|
echo "Dev API already running (PID $(cat "$DEV_API_PID"))"
|
||||||
|
else
|
||||||
|
conda run --no-capture-output -n cf uvicorn app.main:app \
|
||||||
|
--host 0.0.0.0 --port "$API_PORT" --reload \
|
||||||
|
> "$DEV_API_LOG" 2>&1 &
|
||||||
|
echo $! > "$DEV_API_PID"
|
||||||
|
echo "API started (PID $!) → http://localhost:${API_PORT}"
|
||||||
|
fi
|
||||||
|
if [[ -f "$DEV_WEB_PID" ]] && kill -0 "$(cat "$DEV_WEB_PID")" 2>/dev/null; then
|
||||||
|
echo "Dev web already running (PID $(cat "$DEV_WEB_PID"))"
|
||||||
|
else
|
||||||
|
(cd frontend && npm run dev -- --host 0.0.0.0 --port "$WEB_PORT" \
|
||||||
|
> "../$DEV_WEB_LOG" 2>&1) &
|
||||||
|
echo $! > "$DEV_WEB_PID"
|
||||||
|
echo "Web started (PID $!) → http://localhost:${WEB_PORT}"
|
||||||
|
fi
|
||||||
|
echo "Logs: $DEV_API_LOG $DEV_WEB_LOG"
|
||||||
|
;;
|
||||||
|
dev-stop)
|
||||||
|
for pidfile in "$DEV_API_PID" "$DEV_WEB_PID"; do
|
||||||
|
if [[ -f "$pidfile" ]]; then
|
||||||
|
pid=$(cat "$pidfile")
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill "$pid" && echo "Stopped PID $pid"
|
||||||
|
fi
|
||||||
|
rm -f "$pidfile"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Dev servers stopped."
|
||||||
|
;;
|
||||||
|
dev-logs)
|
||||||
|
tail -f "$DEV_API_LOG" "$DEV_WEB_LOG"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ _ALL_FACT_TYPES = [
|
||||||
|
|
||||||
def test_ingest_all_eight_fact_types(client):
|
def test_ingest_all_eight_fact_types(client):
|
||||||
facts = [{"fact_type": ft, "confidence": 0.9, "payload": p} for ft, p in _ALL_FACT_TYPES]
|
facts = [{"fact_type": ft, "confidence": 0.9, "payload": p} for ft, p in _ALL_FACT_TYPES]
|
||||||
resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts})
|
resp = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["facts_received"] == 8
|
assert data["facts_received"] == 8
|
||||||
|
|
@ -89,10 +89,10 @@ def test_ingest_idempotent(client):
|
||||||
}}]
|
}}]
|
||||||
payload = {"source": _SOURCE, "facts": facts}
|
payload = {"source": _SOURCE, "facts": facts}
|
||||||
|
|
||||||
first = client.post("/api/v1/knowledge/ingest", json=payload).json()
|
first = client.post("/api/v1/knowledge/glean", json=payload).json()
|
||||||
assert first["facts_inserted"] == 1
|
assert first["facts_inserted"] == 1
|
||||||
|
|
||||||
second = client.post("/api/v1/knowledge/ingest", json=payload).json()
|
second = client.post("/api/v1/knowledge/glean", json=payload).json()
|
||||||
assert second["facts_inserted"] == 0
|
assert second["facts_inserted"] == 0
|
||||||
assert second["facts_skipped_duplicate"] == 1
|
assert second["facts_skipped_duplicate"] == 1
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ def test_ingest_partial_batch_continues_on_bad_fact(client):
|
||||||
"plant": "Lavender", "use": "calming", "preparation": "tea", "cautions": None,
|
"plant": "Lavender", "use": "calming", "preparation": "tea", "cautions": None,
|
||||||
}},
|
}},
|
||||||
]
|
]
|
||||||
resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts})
|
resp = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts})
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -115,7 +115,7 @@ def test_ingest_valid_and_empty_payload(client):
|
||||||
facts = [{"fact_type": "history_context", "confidence": 0.6, "payload": {
|
facts = [{"fact_type": "history_context", "confidence": 0.6, "payload": {
|
||||||
"subject": "Wisteria", "origin": "China", "period": "Tang Dynasty", "summary": "Introduced to Europe in 1816.",
|
"subject": "Wisteria", "origin": "China", "period": "Tang Dynasty", "summary": "Introduced to Europe in 1816.",
|
||||||
}}]
|
}}]
|
||||||
resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts})
|
resp = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["facts_inserted"] == 1
|
assert resp.json()["facts_inserted"] == 1
|
||||||
|
|
||||||
|
|
@ -124,8 +124,8 @@ def test_ingest_source_is_upserted_across_calls(client):
|
||||||
facts = [{"fact_type": "instruction", "confidence": 0.8, "payload": {
|
facts = [{"fact_type": "instruction", "confidence": 0.8, "payload": {
|
||||||
"action": "dig", "subject": "bed", "steps": [], "timing": "autumn",
|
"action": "dig", "subject": "bed", "steps": [], "timing": "autumn",
|
||||||
}}]
|
}}]
|
||||||
r1 = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts}).json()
|
r1 = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts}).json()
|
||||||
r2 = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts}).json()
|
r2 = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts}).json()
|
||||||
assert r1["source_id"] == r2["source_id"]
|
assert r1["source_id"] == r2["source_id"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -134,7 +134,7 @@ def test_ingest_subject_extracted_for_companion_planting(client):
|
||||||
"plants": ["basil", "tomato"], "relationship": "beneficial",
|
"plants": ["basil", "tomato"], "relationship": "beneficial",
|
||||||
"benefit": "repels aphids", "spacing": None, "notes": None,
|
"benefit": "repels aphids", "spacing": None, "notes": None,
|
||||||
}}]
|
}}]
|
||||||
resp = client.post("/api/v1/knowledge/ingest", json={"source": _SOURCE, "facts": facts})
|
resp = client.post("/api/v1/knowledge/glean", json={"source": _SOURCE, "facts": facts})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
query = client.get("/api/v1/knowledge?fact_type=companion_planting")
|
query = client.get("/api/v1/knowledge?fact_type=companion_planting")
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ _SOURCE_B = {
|
||||||
|
|
||||||
|
|
||||||
def _ingest(client, source, facts):
|
def _ingest(client, source, facts):
|
||||||
return client.post("/api/v1/knowledge/ingest", json={"source": source, "facts": facts}).json()
|
return client.post("/api/v1/knowledge/glean", json={"source": source, "facts": facts}).json()
|
||||||
|
|
||||||
|
|
||||||
def test_query_all_returns_inserted_facts(client):
|
def test_query_all_returns_inserted_facts(client):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue