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
Build and Push Agent API / build (push) Successful in 7s
This commit is contained in:
+113
-5
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user