Enhance Seerr skill: add authentication flow, improve session management, and implement logging for tool execution and issue submission
Build and Push Agent API / build (push) Successful in 7s

This commit is contained in:
2026-05-14 15:36:31 +02:00
parent 0634e7400a
commit 1d821d18fe
+113 -5
View File
@@ -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"))
# ---------------------------------------------------------------------------
# Helpers
# Auth — cookie-based session (preferred) or API key fallback
# ---------------------------------------------------------------------------
def _client() -> httpx.AsyncClient:
return httpx.AsyncClient(
_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,
headers={"X-Api-Key": SEERR_API_KEY},
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
# ---------------------------------------------------------------------------
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):