feat(jellyseer): stats backend — provider, stat widgets, router (slice 1/3)
Groundwork for Jellyseerr request stats in the Jellyfin service, behind a
small reusable abstraction so future stats services (Sonarr/Radarr) reuse it.
Backend:
- JellyseerrClient.request_count() -> /api/v1/request/count (normalized
total/pending/approved/declined/processing/available) and recent_requests()
-> /api/v1/request mapped to {name,type,status,media_status,created_at}
with numeric status enums labelled.
- widgets/stats_provider.py: StatsProvider protocol + registry keyed by
service_type (StatValue/StatsResult). A thin generic interface.
- widgets/jellyseerr_stats.py: JellyseerrStatsProvider registered for the
Jellyfin service; reuses one authenticated client per service (lru_cache) and
caches the StatsResult for ~10s under a lock, so multiple widgets + the tab
collapse onto one Jellyseerr fetch (same lesson as the qBittorrent client).
Accepts jellyseerr_api_key from secrets OR config during the upcoming
config->secret migration.
- Jellyfin service gains two widget kinds: `stat` (a Literal selector over the
six stats — the "extract one value into a widget" affordance) and
`stats_overview` (all stats + recent list).
- widgets router routes widget_kind in {stat, stats_overview} to a generic
StatsWidgetSource (dispatches to the service type's provider), independent of
service type.
- new /api/jellyseerr/stats router endpoint for the Requests tab (resolves the
Jellyfin service by id or first-enabled; shares the provider cache).
Tests: provider normalization, not-configured, TTL caching; stat selector +
overview + unknown-stat widget dispatch; 7 new tests. 400/400 backend pass;
ruff clean.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/clients
|
||||
|
||||
## role
|
||||
Provides external service integration clients (APIs, SSH, local execution) for fetching data from media platforms and directory services.
|
||||
Provides API and system client wrappers for external service integration (media servers, identity providers, torrent clients, and remote/local hosts).
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
|
||||
@@ -4,20 +4,20 @@ dir: backend/src/media_library_viewer_api/clients
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides external service integration clients (APIs, SSH, local execution) for fetching data from media platforms and directory services.
|
||||
Provides API and system client wrappers for external service integration (media servers, identity providers, torrent clients, and remote/local hosts).
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- authentik.py | API client wrapper for Authentik directory service providing paginated user browsing and search via REST API. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- http_timeout.py | Provides a helper function to build decoupled (connect, read) timeout tuples for the `requests` library, allowing different timeout budgets for connection and read phases. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float
|
||||
- jellyfin.py | Wraps the Jellyfin/Emby HTTP API to provide methods for fetching users, libraries, media items, playback sessions, and image URLs as plain Python dictionaries. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:resolve_user_id(self, identifier: str | None) → str, call:self.users, call:any, call:str, call:u.get, call:next, call:logger.info, call:logger.warning, raise:RuntimeError, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- jellyseerr.py | HTTP API client wrapper for Jellyseerr to fetch user data and enrich Jellyfin user lists. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- jellyseerr.py | HTTP API client wrapper for Jellyseerr that fetches enriched user data, request counts, and recent media requests. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info, method:request_count(self) → dict[str, int], call:self.get, call:isinstance, call:int, call:payload.get, call:logger.info, method:recent_requests(self, take) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:mapped.append, call:media.get, call:_label, call:(media or {}).get, func:_label(value: Any, table: dict[int, str]) → str, call:table.get, call:int, call:str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
|
||||
- qbittorrent.py | Minimal read-only qBittorrent Web API client that handles authentication and fetches maindata with caching and backoff. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:threading.Lock, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:time.time, call:self._snapshot.get, call:self._copy_snapshot, call:self._fetch_maindata_incremental, call:self._apply_update, call:min, call:logger.warning, raise:RuntimeError, method:_fetch_maindata_incremental(self) → dict[str, Any], call:self._get, method:_apply_update(self, update: dict[str, Any]) → None, call:bool, call:update.get, call:snap.clear, call:dict, call:list, call:isinstance, call:snap["server_state"].update, call:changed.items, call:snap["torrents"].pop, call:snap["categories"].update, call:snap["categories"].pop, method:_copy_snapshot(self) → dict[str, Any], call:dict, call:snap.get, call:list | dep: logging, threading, time, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- qbittorrent.py | Minimal read-only qBittorrent Web API client that authenticates via username/password and fetches/merges incremental sync/maindata snapshots with caching, locking, and exponential backoff. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:threading.Lock, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:time.time, call:self._snapshot.get, call:self._copy_snapshot, call:self._fetch_maindata_incremental, call:self._apply_update, call:min, call:logger.warning, raise:RuntimeError, method:_fetch_maindata_incremental(self) → dict[str, Any], call:self._get, method:_apply_update(self, update: dict[str, Any]) → None, call:bool, call:update.get, call:snap.clear, call:dict, call:list, call:isinstance, call:snap["server_state"].update, call:changed.items, call:snap["torrents"].pop, call:snap["categories"].update, call:snap["categories"].pop, method:_copy_snapshot(self) → dict[str, Any], call:dict, call:snap.get, call:list | dep: logging, threading, time, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
|
||||
## arch
|
||||
Thin wrapper pattern around HTTP libraries (requests) and protocol clients (paramiko SSH), with uniform dict-based outputs, shared timeout configuration, and caching/backoff strategies.
|
||||
Adapter pattern with per-service client classes wrapping REST/SSH APIs into standardized Python dictionaries; shared HTTP timeout helper and mix-and-match local/remote execution clients.
|
||||
## tags
|
||||
error, call:logger.info, call:self., call:logger.debug, call:logger.warning, call:self.get, client, init
|
||||
call:logger.info, error, call:self.get, call:self., call:logger.debug, call:logger.warning, client, init
|
||||
## symbols
|
||||
- AuthentikClient
|
||||
- JellyfinClient
|
||||
|
||||
@@ -15,6 +15,23 @@ from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT,
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Jellyseerr numeric status enums (see Overseerr/Jellyseerr source).
|
||||
_REQUEST_STATUS: dict[int, str] = {1: "pending", 2: "approved", 3: "declined"}
|
||||
_MEDIA_STATUS: dict[int, str] = {
|
||||
1: "unknown",
|
||||
2: "pending",
|
||||
3: "processing",
|
||||
4: "partially_available",
|
||||
5: "available",
|
||||
}
|
||||
|
||||
|
||||
def _label(value: Any, table: dict[int, str]) -> str:
|
||||
try:
|
||||
return table.get(int(value), str(value))
|
||||
except (TypeError, ValueError):
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
class JellyseerrClient:
|
||||
"""Small wrapper around the Jellyseerr REST API."""
|
||||
@@ -136,3 +153,45 @@ class JellyseerrClient:
|
||||
|
||||
logger.info("Jellyseerr returned %s users", len(results))
|
||||
return results
|
||||
|
||||
def request_count(self) -> dict[str, int]:
|
||||
"""Return normalized request counts from /api/v1/request/count.
|
||||
|
||||
Jellyseerr reports pending/approved/declined/processing/available/total.
|
||||
Missing keys default to 0 so callers can rely on a stable shape.
|
||||
"""
|
||||
payload = self.get("/request/count")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
keys = ("total", "pending", "approved", "declined", "processing", "available")
|
||||
counts = {k: int(payload.get(k) or 0) for k in keys}
|
||||
logger.info(
|
||||
"Jellyseerr request counts total=%s pending=%s processing=%s",
|
||||
counts["total"],
|
||||
counts["pending"],
|
||||
counts["processing"],
|
||||
)
|
||||
return counts
|
||||
|
||||
def recent_requests(self, take: int = 20) -> list[dict[str, Any]]:
|
||||
"""Return the most recently modified requests, lightly mapped."""
|
||||
take = max(1, min(int(take), 100))
|
||||
payload = self.get("/request", sort="modified", skip=0, take=take)
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
results = payload.get("results") or []
|
||||
items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
|
||||
mapped: list[dict[str, Any]] = []
|
||||
for r in items:
|
||||
media = r.get("media") or {}
|
||||
mapped.append(
|
||||
{
|
||||
"id": r.get("id"),
|
||||
"type": r.get("type"),
|
||||
"name": r.get("title") or media.get("title") or media.get("name") or "—",
|
||||
"status": _label(r.get("status"), _REQUEST_STATUS),
|
||||
"media_status": _label((media or {}).get("status"), _MEDIA_STATUS),
|
||||
"created_at": r.get("createdAt"),
|
||||
}
|
||||
)
|
||||
return mapped
|
||||
|
||||
Reference in New Issue
Block a user