This commit is contained in:
+92
-47
@@ -3,11 +3,11 @@ Seerr skill — connects to Overseerr / Jellyseerr API for media discovery,
|
||||
requests, and issue submission.
|
||||
|
||||
.env variables:
|
||||
SEERR_URL – base URL (e.g. https://seerr.example.com)
|
||||
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)
|
||||
SEERR_URL - base URL (e.g. https://seerr.example.com)
|
||||
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:
|
||||
@@ -39,54 +39,103 @@ SEERR_TIMEOUT = int(get_config("SEERR_TIMEOUT", "30"))
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth — cookie-based session (preferred) or API key fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
_seerr_session: httpx.AsyncClient | None = None
|
||||
#
|
||||
# IMPORTANT: httpx.AsyncClient binds internal asyncio primitives to the
|
||||
# event loop that is current when the client is created. The Discord bot
|
||||
# runs in a separate thread with its own event loop, so we must create a
|
||||
# fresh AsyncClient *per event loop*. We cache one client per loop ID so
|
||||
# each loop still reuses its own singleton (connection pooling works), but
|
||||
# the bot and the REST API never fight over the same connection pool.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
_seerr_sessions: dict[int, httpx.AsyncClient] = {}
|
||||
_seerr_sessions_lock = threading.Lock()
|
||||
|
||||
# Cached login cookies — obtained once at module load (sync) and reused
|
||||
# for every event-loop-specific client. A threading.Event ensures that
|
||||
# the first caller to trigger the login blocks all other callers until
|
||||
# the login is complete — preventing a race where a second thread builds
|
||||
# a client with empty cookies before the login finishes.
|
||||
_seerr_cookies: dict = {}
|
||||
_seerr_cookies_ready = threading.Event()
|
||||
_seerr_cookies_lock = threading.Lock()
|
||||
|
||||
|
||||
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.
|
||||
def _ensure_cookies() -> None:
|
||||
"""One-time sync login to get the connect.sid cookie.
|
||||
|
||||
Thread-safe: only one thread performs the login; all others block
|
||||
until it finishes, then reuse the result.
|
||||
"""
|
||||
global _seerr_session
|
||||
|
||||
if _seerr_session is not None:
|
||||
if _seerr_cookies_ready.is_set():
|
||||
return
|
||||
|
||||
cookies: dict = {}
|
||||
with _seerr_cookies_lock:
|
||||
# Double-check — another thread may have finished while we waited
|
||||
if _seerr_cookies_ready.is_set():
|
||||
return
|
||||
|
||||
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()
|
||||
if SEERR_USERNAME.strip() and SEERR_PASSWORD.strip():
|
||||
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()
|
||||
_seerr_cookies.update(dict(sync_client.cookies))
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
finally:
|
||||
sync_client.close()
|
||||
|
||||
# Build the async session
|
||||
if cookies:
|
||||
_seerr_session = httpx.AsyncClient(
|
||||
# Signal completion — even if login failed (empty cookies) so we
|
||||
# don't retry forever.
|
||||
_seerr_cookies_ready.set()
|
||||
|
||||
|
||||
def _build_client() -> httpx.AsyncClient:
|
||||
"""Create a new httpx.AsyncClient for the *current* event loop."""
|
||||
if _seerr_cookies:
|
||||
return httpx.AsyncClient(
|
||||
base_url=SEERR_URL,
|
||||
cookies=cookies, # ← cookie auth
|
||||
cookies=_seerr_cookies,
|
||||
timeout=SEERR_TIMEOUT,
|
||||
)
|
||||
elif SEERR_API_KEY.strip():
|
||||
_seerr_session = httpx.AsyncClient(
|
||||
if SEERR_API_KEY.strip():
|
||||
return 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,
|
||||
)
|
||||
return httpx.AsyncClient(
|
||||
base_url=SEERR_URL,
|
||||
timeout=SEERR_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _get_session() -> httpx.AsyncClient:
|
||||
"""Return an AsyncClient valid for the currently-running event loop.
|
||||
|
||||
On the very first call the sync login is performed (if credentials are
|
||||
configured). After that every event loop gets its own cached client.
|
||||
"""
|
||||
_ensure_cookies()
|
||||
|
||||
try:
|
||||
loop_id = id(asyncio.get_running_loop())
|
||||
except RuntimeError:
|
||||
# No event loop running (e.g. called during module import).
|
||||
# Build a throw-away client — the first real call will recreate it.
|
||||
loop_id = 0
|
||||
|
||||
with _seerr_sessions_lock:
|
||||
if loop_id not in _seerr_sessions:
|
||||
_seerr_sessions[loop_id] = _build_client()
|
||||
return _seerr_sessions[loop_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -95,11 +144,9 @@ def _init_session() -> None:
|
||||
|
||||
|
||||
class _SharedClient:
|
||||
"""Wraps the shared httpx.AsyncClient so that `async with` doesn't
|
||||
"""Wraps a per-loop 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:
|
||||
@@ -113,13 +160,11 @@ class _SharedClient:
|
||||
|
||||
|
||||
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)
|
||||
"""Return a context-manager wrapper around the current loop's session."""
|
||||
return _SharedClient(_get_session())
|
||||
|
||||
|
||||
# Initialise at import time
|
||||
_init_session()
|
||||
# Per-loop sessions are created lazily on first use — no eager init needed.
|
||||
|
||||
|
||||
def _fmt_items(items: list[dict], kind: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user