"""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, )