turnstone/app/ingest/tautulli.py
pyr0ball 4fbac2554e feat: Tautulli webhook ingest endpoint — plex events -> log_entries
POST /turnstone/api/ingest/tautulli accepts Tautulli notification agent
payloads and stores them as log_entries under source 'tautulli'. Severity
maps error->CRITICAL, buffer->WARN, all others->None. Optional bearer token
auth via X-Tautulli-Token header + tautulli_token pref. FTS index rebuilt
as a background task after each write. 28 new tests, all passing.
2026-05-13 18:41:03 -07:00

99 lines
3.1 KiB
Python

"""Tautulli webhook ingestor.
Parses a Tautulli notification agent JSON payload into a single RetrievedEntry.
Tautulli sends all template values as strings, so all fields are treated as str.
"""
from __future__ import annotations
from app.ingest.base import (
apply_patterns,
epoch_float_to_iso,
make_entry_id,
now_iso,
)
from app.services.models import LogPattern, RetrievedEntry
_ACTION_SEVERITY: dict[str, str | None] = {
"error": "CRITICAL",
"buffer": "WARN",
}
def _severity(action: str) -> str | None:
return _ACTION_SEVERITY.get(action.lower())
def _format_text(p: dict) -> str:
action = p.get("action", "").lower()
user = p.get("user") or "unknown"
player = p.get("player") or "unknown player"
grandparent = p.get("grandparent_title", "").strip()
title = p.get("title", "").strip()
media = f'"{grandparent}{title}"' if grandparent else f'"{title}"'
quality = p.get("quality", "")
video_dec = p.get("video_decision", "")
stream = f"{quality}, {video_dec}" if quality and video_dec else quality or video_dec
err = p.get("error_message", "").strip()
if action == "error":
base = f"[plex:error] {user} on {player}: {media}"
return f"{base}{err}" if err else base
if action == "buffer":
return f"[plex:buffer] {user} on {player}: {media} is buffering"
if action in ("play", "resume"):
parts = [f"[plex:{action}] {user} on {player}: {media}"]
if stream:
parts.append(f"({stream})")
return " ".join(parts)
if action == "stop":
return f"[plex:stop] {user} stopped {media} on {player}"
if action == "pause":
return f"[plex:pause] {user} paused {media} on {player}"
return f"[plex:{action}] {user}: {media} on {player}"
def is_tautulli_payload(payload: dict) -> bool:
"""Return True if the payload looks like a Tautulli webhook."""
return "action" in payload and "session_key" in payload
def parse_webhook(
payload: dict,
compiled_patterns: list[tuple[LogPattern, object]],
) -> RetrievedEntry:
"""Parse a Tautulli webhook payload into a single RetrievedEntry."""
source_id = "tautulli"
action = payload.get("action", "")
text = _format_text(payload)
raw_ts = payload.get("timestamp") or ""
try:
ts_float = float(raw_ts) if raw_ts else 0.0
except (ValueError, TypeError):
ts_float = 0.0
if ts_float:
timestamp_iso: str | None = epoch_float_to_iso(ts_float)
timestamp_raw: str | None = raw_ts
else:
timestamp_iso = now_iso()
timestamp_raw = None
ingest_time = now_iso()
severity = _severity(action)
matched = apply_patterns(text, compiled_patterns)
entry_id = make_entry_id(source_id, 0, str(raw_ts) + text)
return RetrievedEntry(
entry_id=entry_id,
source_id=source_id,
sequence=0,
timestamp_raw=timestamp_raw,
timestamp_iso=timestamp_iso,
ingest_time=ingest_time,
severity=severity,
repeat_count=1,
out_of_order=False,
matched_patterns=matched,
text=text,
)