diff --git a/skills/seerr.py b/skills/seerr.py index f806bfb..2127ddd 100644 --- a/skills/seerr.py +++ b/skills/seerr.py @@ -4,8 +4,17 @@ requests, and issue submission. .env variables: SEERR_URL – base URL (e.g. https://seerr.example.com) - SEERR_API_KEY – API key from Seerr settings + SEERR_USERNAME – login username (email) + SEERR_PASSWORD – login password + SEERR_API_KEY – fallback API key (used if username/password not set) SEERR_TIMEOUT – optional request timeout in seconds (default 30) + +Auth flow: + 1. If SEERR_USERNAME + SEERR_PASSWORD are set: + POST /api/v1/auth/jellyfin {username, password} + → stores the connect.sid cookie in a persistent httpx session + → all subsequent requests use cookie auth + 2. Falls back to X-Api-Key header if SEERR_API_KEY is set. """ from __future__ import annotations @@ -21,19 +30,96 @@ from skills import Skill, register, ToolResult, get_config # Config # --------------------------------------------------------------------------- SEERR_URL = (get_config("SEERR_URL") or "").rstrip("/") +SEERR_USERNAME = get_config("SEERR_USERNAME") or "" +SEERR_PASSWORD = get_config("SEERR_PASSWORD") or "" SEERR_API_KEY = get_config("SEERR_API_KEY") or "" SEERR_TIMEOUT = int(get_config("SEERR_TIMEOUT", "30")) +# --------------------------------------------------------------------------- +# Auth — cookie-based session (preferred) or API key fallback +# --------------------------------------------------------------------------- +_seerr_session: httpx.AsyncClient | None = None + + +def _init_session() -> None: + """Initialise the Seerr session once at module load. + Uses httpx.Client (sync!) for the one-time login, then creates an + async client with the resulting cookies. No async event-loop tricks. + """ + global _seerr_session + + if _seerr_session is not None: + return + + cookies: dict = {} + + if SEERR_USERNAME.strip() and SEERR_PASSWORD.strip(): + # --- Cookie-based auth: login via sync client --- + sync_client = httpx.Client(base_url=SEERR_URL, timeout=SEERR_TIMEOUT) + try: + resp = sync_client.post("/api/v1/auth/jellyfin", json={ + "username": SEERR_USERNAME.strip(), + "password": SEERR_PASSWORD.strip(), + }) + resp.raise_for_status() + cookies = dict(sync_client.cookies) + except httpx.HTTPError: + pass # fall through to API key + finally: + sync_client.close() + + # Build the async session + if cookies: + _seerr_session = httpx.AsyncClient( + base_url=SEERR_URL, + cookies=cookies, # ← cookie auth + timeout=SEERR_TIMEOUT, + ) + elif SEERR_API_KEY.strip(): + _seerr_session = httpx.AsyncClient( + base_url=SEERR_URL, + headers={"X-Api-Key": SEERR_API_KEY.strip()}, + timeout=SEERR_TIMEOUT, + ) + else: + _seerr_session = httpx.AsyncClient( + base_url=SEERR_URL, + timeout=SEERR_TIMEOUT, + ) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -def _client() -> httpx.AsyncClient: - return httpx.AsyncClient( - base_url=SEERR_URL, - headers={"X-Api-Key": SEERR_API_KEY}, - timeout=SEERR_TIMEOUT, - ) + + +class _SharedClient: + """Wraps the shared httpx.AsyncClient so that `async with` doesn't + close it when the context exits. All 11 call sites use: + async with _client() as c: + Without this wrapper, httpx would close the shared session after + the first call, breaking every subsequent tool execution. + """ + + def __init__(self, client: httpx.AsyncClient) -> None: + self._client = client + + async def __aenter__(self) -> httpx.AsyncClient: + return self._client + + async def __aexit__(self, *args: object) -> None: + pass # do NOT close the shared session + + +def _client() -> _SharedClient: + """Return a context-manager wrapper around the shared httpx session.""" + assert _seerr_session is not None, "Seerr session not initialised" + return _SharedClient(_seerr_session) + + +# Initialise at import time +_init_session() def _fmt_items(items: list[dict], kind: str) -> str: @@ -326,8 +412,20 @@ async def _execute(tool_name: str, args: dict) -> ToolResult: handler = handlers.get(tool_name) if not handler: return ToolResult.fail(f"Unknown tool: {tool_name}") + + logger.info( + "🔧 TOOL CALL: %s | args=%s", + tool_name, + {k: v for k, v in args.items() if k not in ("description",)}, + ) + try: result = await handler(args) + status = "✅" if result.success else "❌" + logger.info( + "%s TOOL RESULT: %s → %.300s", + status, tool_name, result.content, + ) return result except httpx.HTTPStatusError as exc: status = exc.response.status_code @@ -734,6 +832,9 @@ async def _submit_issue(args: dict) -> ToolResult: issue_type = args.get("issue_type", 4) # numeric code: 1=video, 2=audio, 3=sub, 4=other media_id = args.get("media_id") + import logging + logger = logging.getLogger("skills.seerr") + body: dict = { "issueType": int(issue_type), "message": description, @@ -741,6 +842,8 @@ async def _submit_issue(args: dict) -> ToolResult: if media_title: body["message"] = f"[Media: {media_title}]\n\n{description}" + logger.info("📝 SUBMIT_ISSUE body=%s media_title=%s", body, media_title) + async with _client() as c: # --- Resolve mediaId (Seerr internal ID for /issue endpoint) --- if not media_id and media_title: @@ -750,6 +853,8 @@ async def _submit_issue(args: dict) -> ToolResult: search_r.raise_for_status() results = search_r.json().get("results", []) + logger.info("🔍 Search for '%s' → %d results", media_title, len(results)) + # Filter to actual media (not persons) and prefer exact title match media_results = [ item for item in results @@ -758,11 +863,14 @@ async def _submit_issue(args: dict) -> ToolResult: if media_results: media_info = media_results[0].get("mediaInfo", {}) media_id = media_info.get("id") or media_results[0].get("id") + logger.info("🔍 Resolved mediaId=%s for '%s'", media_id, media_title) if media_id: body["mediaId"] = int(media_id) + logger.info("📤 POST /api/v1/issue body=%s", body) r = await c.post("/api/v1/issue", json=body) + logger.info("📥 Response status=%s body=%s", r.status_code, r.text[:500]) resp_json = r.json() if r.text else {} if r.status_code in (200, 201):