diff --git a/backend/src/media_library_viewer_api/clients/.pi-map.index.md b/backend/src/media_library_viewer_api/clients/.pi-map.index.md index f242e97..e3db1b0 100644 --- a/backend/src/media_library_viewer_api/clients/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/clients/.pi-map.index.md @@ -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 diff --git a/backend/src/media_library_viewer_api/clients/.pi-map.md b/backend/src/media_library_viewer_api/clients/.pi-map.md index 81a25ef..364817b 100644 --- a/backend/src/media_library_viewer_api/clients/.pi-map.md +++ b/backend/src/media_library_viewer_api/clients/.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 diff --git a/backend/src/media_library_viewer_api/clients/jellyseerr.py b/backend/src/media_library_viewer_api/clients/jellyseerr.py index 4b6d22f..71a3155 100644 --- a/backend/src/media_library_viewer_api/clients/jellyseerr.py +++ b/backend/src/media_library_viewer_api/clients/jellyseerr.py @@ -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 diff --git a/backend/src/media_library_viewer_api/integrations/.pi-map.index.md b/backend/src/media_library_viewer_api/integrations/.pi-map.index.md index 6fc2cd3..2f9c7b9 100644 --- a/backend/src/media_library_viewer_api/integrations/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/integrations/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/integrations ## role -Provides pluggable external service integrations (e.g., Alertmanager, Jellyfin, Prometheus, qBittorrent) with standardized configuration, connection testing, and widget definitions for a monitoring dashboard. +Provides pluggable external service integrations with standardized configuration, connection testing, and widget definitions for the media library viewer API. ## parent index: backend/src/media_library_viewer_api/.pi-map.index.md map: backend/src/media_library_viewer_api/.pi-map.md diff --git a/backend/src/media_library_viewer_api/integrations/.pi-map.md b/backend/src/media_library_viewer_api/integrations/.pi-map.md index 847d33a..07e5a30 100644 --- a/backend/src/media_library_viewer_api/integrations/.pi-map.md +++ b/backend/src/media_library_viewer_api/integrations/.pi-map.md @@ -4,21 +4,21 @@ dir: backend/src/media_library_viewer_api/integrations index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md ## role -Provides pluggable external service integrations (e.g., Alertmanager, Jellyfin, Prometheus, qBittorrent) with standardized configuration, connection testing, and widget definitions for a monitoring dashboard. +Provides pluggable external service integrations with standardized configuration, connection testing, and widget definitions for the media library viewer API. ## files - __init__.py | Defines a closed registry module for service integrations. - alertmanager.py | Defines a service integration for Prometheus Alertmanager, providing configuration models, connection testing, alert summarization, and widget definitions for displaying active alerts. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:int, call:secrets.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get("versionInfo", {}).get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store - authentik.py | Defines the Authentik service integration for user-directory access, including connection config, API token secret management, and a connection test. | exp: class:AuthentikConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:secrets.get, call:float, call:AuthentikClient, call:client.users, call:result.get, call:isinstance, call:TestResult, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.authentik, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.authentik.AuthentikClient - backups.py | Defines a Backups service type with configuration and summary widget for monitoring backup jobs, run history, and alerting. | exp: class:BackupsConfig, class:BackupsSummaryWidgetConfig | dep: media_library_viewer_api.integrations.base - base.py | Provides base classes and utility functions for defining external service integrations, including config schemas, secrets, widgets, and connection error translation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:TestResult, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump, func:translate_connection_error(exc: Exception, context) → TestResult, call:str, call:message.lower, call:isinstance, call:TestResult | dep: asyncio, dataclasses, typing, requests, pydantic, media_library_viewer_api.services.settings_store -- jellyfin.py | Defines the Jellyfin media server service integration, including connection testing, configuration models, and widget definitions for activity monitoring. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str, call:config.get, call:secrets.get, call:int, call:JellyfinClient, call:client.users, call:TestResult, call:len, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.jellyfin.JellyfinClient, media_library_viewer_api.services.settings_store.SettingsStore +- jellyfin.py | Defines the Jellyfin service integration including connection config, widget types, and connection testing for a media library viewer API. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig, class:JellyfinRequestStatWidgetConfig, class:JellyfinRequestsOverviewWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str, call:config.get, call:secrets.get, call:int, call:JellyfinClient, call:client.users, call:TestResult, call:len, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.jellyfin.JellyfinClient, media_library_viewer_api.services.settings_store.SettingsStore - nextcloud.py | Defines a Nextcloud service integration with connection testing and configuration for a media library viewer API. | exp: class:NextcloudConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store - prometheus.py | Defines the Prometheus service integration for a media library viewer API, including connection testing via a Grafana gateway and configuration models for metric, chart, gauge, and mean widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("grafana_url") or "").rstrip, call:config.get, call:secrets.get, call:int, call:TestResult, call:requests.post, call:resp.raise_for_status, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store - qbittorrent.py | Defines the qBittorrent service integration, including connection config models, secret fields, widget definitions (totals, active, speed), and a connection test function. | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig, class:QbittorrentSpeedWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:config.get, call:secrets.get, call:int, call:QbittorrentClient, call:client.maindata, call:data.get("server_state", {}).get, call:TestResult, call:str(exc).lower, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.qbittorrent.QbittorrentClient, media_library_viewer_api.services.settings_store.SettingsStore - registry.py | Maintains a closed registry of service definitions and provides lookup functions to query available services, their types, and widget kinds. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.authentik, media_library_viewer_api.integrations.backups, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.qbittorrent, media_library_viewer_api.integrations.ssh_tasks - ssh_tasks.py | Defines a service plugin that runs reusable saved tasks over SSH by managing connection configuration, secrets, and connection testing. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("host") or "").strip, call:config.get, call:int, call:ServiceRecord, call:build_ssh_client, call:client.connect, call:str(exc).lower, call:TestResult, call:translate_connection_error, call:client.close | dep: typing, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources ## arch -Registry-based plugin pattern with abstract base classes defining config schemas, secrets, widgets, and connection tests; each integration is a self-contained module auto-registered in a closed central registry for discovery and lookup. +Plugin/registry pattern with abstract base classes defining contracts for config schemas, secrets, widgets, and connection testing, discovered through a closed central registry. ## tags config, connection, widget, media_library_viewer_api, service, error, integrations, test ## symbols diff --git a/backend/src/media_library_viewer_api/integrations/jellyfin.py b/backend/src/media_library_viewer_api/integrations/jellyfin.py index a13d502..06b2f1b 100644 --- a/backend/src/media_library_viewer_api/integrations/jellyfin.py +++ b/backend/src/media_library_viewer_api/integrations/jellyfin.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.integrations.base import ( @@ -67,6 +67,25 @@ class JellyfinNowPlayingWidgetConfig(WidgetConfigBase): pass +class JellyfinRequestStatWidgetConfig(WidgetConfigBase): + """A single Jellyseerr request stat (e.g. pending / approved / total).""" + + stat: Literal[ + "total", + "pending", + "approved", + "declined", + "processing", + "available", + ] = "pending" + + +class JellyfinRequestsOverviewWidgetConfig(WidgetConfigBase): + """Grid of all Jellyseerr request stats + a recent-requests list.""" + + pass + + DEFINITION = ServiceDefinition( service_type="jellyfin", name="Jellyfin", @@ -92,6 +111,22 @@ DEFINITION = ServiceDefinition( default_config={}, refresh_interval_ms=30_000, ), + widget_kind( + kind="stat", + name="Request stat", + description="A single Jellyseerr request statistic (e.g. pending requests).", + model_cls=JellyfinRequestStatWidgetConfig, + default_config={"stat": "pending"}, + refresh_interval_ms=60_000, + ), + widget_kind( + kind="stats_overview", + name="Requests overview", + description="All Jellyseerr request stats plus a recent-requests list.", + model_cls=JellyfinRequestsOverviewWidgetConfig, + default_config={}, + refresh_interval_ms=60_000, + ), ], test_callable=test_connection, ) diff --git a/backend/src/media_library_viewer_api/main.py b/backend/src/media_library_viewer_api/main.py index aaf2a56..1e5dd0b 100644 --- a/backend/src/media_library_viewer_api/main.py +++ b/backend/src/media_library_viewer_api/main.py @@ -27,6 +27,7 @@ from media_library_viewer_api.routers import ( from media_library_viewer_api.routers import backups as backups_router from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks from media_library_viewer_api.routers import dashboards as dashboards_router +from media_library_viewer_api.routers import jellyseerr as jellyseerr_router from media_library_viewer_api.routers import services as services_router from media_library_viewer_api.routers import widgets as widgets_router from media_library_viewer_api.routers.settings import router as settings_router @@ -169,6 +170,7 @@ app.include_router(settings_router) app.include_router(backups_router.router) app.include_router(widgets_router.router) app.include_router(dashboards_router.router) +app.include_router(jellyseerr_router.router) app.include_router(services_router.router) app.include_router(authentik_users_router.router) diff --git a/backend/src/media_library_viewer_api/routers/.pi-map.index.md b/backend/src/media_library_viewer_api/routers/.pi-map.index.md index aa25f57..24c4cab 100644 --- a/backend/src/media_library_viewer_api/routers/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/routers/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/routers ## role -FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets). +FastAPI router package that organizes and exposes all REST API endpoints for the media library viewer backend, covering authentication, dashboards, media management, backups, monitoring, services, files, and task execution. ## parent index: backend/src/media_library_viewer_api/.pi-map.index.md map: backend/src/media_library_viewer_api/.pi-map.md @@ -15,6 +15,7 @@ map: backend/src/media_library_viewer_api/.pi-map.md - dashboard.py - dashboards.py - files.py +- jellyseerr.py - jobs.py - media.py - monitoring.py diff --git a/backend/src/media_library_viewer_api/routers/.pi-map.md b/backend/src/media_library_viewer_api/routers/.pi-map.md index ac9a756..3635651 100644 --- a/backend/src/media_library_viewer_api/routers/.pi-map.md +++ b/backend/src/media_library_viewer_api/routers/.pi-map.md @@ -4,25 +4,26 @@ dir: backend/src/media_library_viewer_api/routers index: backend/src/media_library_viewer_api/routers/.pi-map.index.md ## role -FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets). +FastAPI router package that organizes and exposes all REST API endpoints for the media library viewer backend, covering authentication, dashboards, media management, backups, monitoring, services, files, and task execution. ## files - __init__.py | Marks the directory as a Python package for routers. - authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources -- backups.py | FastAPI router for receiving backup run reports, managing backup jobs/runs, and generating/acknowledging backup alerts. | exp: func:_resolve_backup_service_id(store: SettingsStore, explicit) → str, call:store.list_services, call:svc.get, func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store +- backups.py | FastAPI router providing REST endpoints for reporting, querying, and managing backup jobs, runs, and alerts. | exp: func:_resolve_backup_service_id(store: SettingsStore, explicit) → str, call:store.list_services, call:svc.get, func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(service_id, store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, service_id, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, service_id, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store - dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store - dashboards.py | Provides CRUD API endpoints for managing named dashboards via a FastAPI router. | exp: func:list_dashboards(store) → list[NamedDashboard], call:store.list_dashboards, call:NamedDashboard, func:get_dashboard_by_slug(slug: str, store) → NamedDashboard, call:store.get_dashboard_by_slug, call:NamedDashboard, raise:HTTPException, func:create_dashboard(body: NamedDashboardInput, store) → NamedDashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, func:update_dashboard(dashboard_id: str, body: NamedDashboardInput, store) → NamedDashboard, call:store.get_dashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, raise:HTTPException, func:delete_dashboard(dashboard_id: str, store) → dict[str, str], call:store.get_dashboard, call:store.delete_dashboard, raise:HTTPException | dep: fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.models.dashboards, media_library_viewer_api.services.settings_store - files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils +- jellyseerr.py | FastAPI router that resolves a Jellyfin service instance and delegates to the Jellyseerr stats provider to return request counts and recent requests. | exp: func:_serialize(result) → dict, func:get_jellyseerr_stats(jellyfin_service_id, store) → dict, call:resolve_service_record, call:get_stats_provider, call:provider.fetch_stats, call:logger.exception, call:_serialize, raise:HTTPException | dep: logging, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets, media_library_viewer_api.widgets.stats_provider, media_library_viewer_api.widgets.jellyseerr_stats - jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs - media.py | FastAPI router providing endpoints to manage media index lifecycle operations including status checks, building (via subprocess workers), stopping, force-stopping, and querying the media library index. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path, service_id) → list[str], call:str, func:_start_worker(index: MediaIndex, service_id) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(jellyfin_service_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:logger.info, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, jellyfin_service_id, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index -- monitoring.py | FastAPI router providing monitoring observability endpoints that proxy and aggregate status, alerts, and scrape targets from Alertmanager and Prometheus. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager -- services.py | Provides REST API endpoints for managing service instances in a service registry, including listing service types and CRUD operations for instances while ensuring plaintext secrets are never exposed. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store +- monitoring.py | FastAPI router providing observability endpoints for monitoring machines, Alertmanager alerts/status, Prometheus targets/status, and webhook ingestion. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → tuple[float, float], call:int, call:service.config.get, call:http_timeout, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:requests.post, call:http_timeout, call:resp.raise_for_status, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.clients.http_timeout, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager, fastapi.APIRouter +- services.py | Provides REST API endpoints for listing service types and performing CRUD operations on service instances, including validation and connectivity testing. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException, func:test_instance(body: ServiceInstanceInput, store) → dict[str, Any], call:_validate_input, call:require_service_definition, call:logger.info, call:definition.test_callable, call:logger.exception, call:TestResult | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store - settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store - tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources -- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances and their references, including CRUD operations, data fetching, and live-link detachments. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources +- widgets.py | Provides a FastAPI REST API for CRUD operations on dashboard widget instances and widget references (live-links), including data fetching through registered adapters. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_stats_adapter, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources ## arch -Modular FastAPI APIRouter pattern where each domain module exports its own router instance; routers encapsulate endpoint definitions and delegate business logic to underlying service clients, SSH utilities, and subprocess workers. +Modular router-per-domain pattern where each file defines an isolated FastAPI APIRouter for a specific functional area, enabling independent endpoint registration, dependency injection, and delegation to underlying service clients and providers. ## tags -call:, raise:httpexception, service, backup, get, media_library_viewer_api, ssh, call:store.get +call:, raise:httpexception, service, media_library_viewer_api, backup, get, ssh, call:logger.info ## symbols - MessageRequest - RunJobRequest diff --git a/backend/src/media_library_viewer_api/routers/jellyseerr.py b/backend/src/media_library_viewer_api/routers/jellyseerr.py new file mode 100644 index 0000000..093f08b --- /dev/null +++ b/backend/src/media_library_viewer_api/routers/jellyseerr.py @@ -0,0 +1,52 @@ +"""Jellyseerr request stats — powers the Requests tab on the Jellyfin page. + +Jellyseerr is an optional companion of the Jellyfin service. This router +resolves the Jellyfin service instance (by ``jellyfin_service_id`` or the first +enabled one) and delegates to the registered Jellyseerr stats provider, which +shares its short-TTL cache with the ``stat`` / ``stats_overview`` widgets so +the tab and the widgets don't each hit Jellyseerr. +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.services.service_resolution import resolve_service_record +from media_library_viewer_api.services.settings_store import SettingsStore +from media_library_viewer_api.widgets import jellyseerr_stats # noqa: F401 — ensure provider registration +from media_library_viewer_api.widgets.stats_provider import get_stats_provider + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/jellyseerr", tags=["jellyseerr"]) + + +def _serialize(result) -> dict: + return { + "stats": [{"key": s.key, "label": s.label, "value": s.value} for s in result.stats], + "recent": result.recent, + "detail": result.detail, + } + + +@router.get("/stats") +def get_jellyseerr_stats( + jellyfin_service_id: str | None = None, + store: SettingsStore = Depends(get_settings_store), +) -> dict: + """Return Jellyseerr request counts + a recent-requests list.""" + service = resolve_service_record(store, "jellyfin", jellyfin_service_id) + if service is None: + raise HTTPException(status_code=503, detail="No Jellyfin service is configured.") + provider = get_stats_provider("jellyfin") + if provider is None: # pragma: no cover - registered at import + raise HTTPException(status_code=503, detail="Jellyseerr stats provider is not available.") + try: + result = provider.fetch_stats(service) + except Exception as exc: # pragma: no cover - provider guards internally + logger.exception("Jellyseerr stats endpoint failed") + raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc + return _serialize(result) diff --git a/backend/src/media_library_viewer_api/routers/widgets.py b/backend/src/media_library_viewer_api/routers/widgets.py index a4a0ccf..f17155c 100644 --- a/backend/src/media_library_viewer_api/routers/widgets.py +++ b/backend/src/media_library_viewer_api/routers/widgets.py @@ -33,6 +33,7 @@ from media_library_viewer_api.widgets.sources import ( build_service_record, get_builtin_adapter, get_service_adapter, + get_stats_adapter, ) @@ -199,7 +200,11 @@ async def fetch_data( error="Service is disabled", fetched_at=int(time.time()), ).model_dump() - adapter = get_service_adapter(service_row["service_type"]) + adapter = ( + get_stats_adapter() + if widget_kind in ("stat", "stats_overview") + else get_service_adapter(service_row["service_type"]) + ) if adapter is None: return WidgetDataResponse( widget_id=widget_id, diff --git a/backend/src/media_library_viewer_api/widgets/.pi-map.index.md b/backend/src/media_library_viewer_api/widgets/.pi-map.index.md index c33159a..8c1872e 100644 --- a/backend/src/media_library_viewer_api/widgets/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/widgets/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/widgets ## role -Provides widget data adapters and configuration definitions that fetch, normalize, and validate content from both built-in and external services for dashboard display. +Provides data fetching, normalization, and configuration logic for dashboard widgets across various integrated services and data sources. ## parent index: backend/src/media_library_viewer_api/.pi-map.index.md map: backend/src/media_library_viewer_api/.pi-map.md @@ -11,13 +11,15 @@ map: backend/src/media_library_viewer_api/.pi-map.md ## files - __init__.py - builtin.py +- jellyseerr_stats.py - prometheus_range.py - sources.py +- stats_provider.py ## links index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md map: backend/src/media_library_viewer_api/widgets/.pi-map.md ## workflows - change widgets behavior - read: __init__.py, builtin.py, prometheus_range.py + read: __init__.py, builtin.py, jellyseerr_stats.py ## dirty - diff --git a/backend/src/media_library_viewer_api/widgets/.pi-map.md b/backend/src/media_library_viewer_api/widgets/.pi-map.md index 07fb80d..7a6a667 100644 --- a/backend/src/media_library_viewer_api/widgets/.pi-map.md +++ b/backend/src/media_library_viewer_api/widgets/.pi-map.md @@ -4,27 +4,29 @@ dir: backend/src/media_library_viewer_api/widgets index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md ## role -Provides widget data adapters and configuration definitions that fetch, normalize, and validate content from both built-in and external services for dashboard display. +Provides data fetching, normalization, and configuration logic for dashboard widgets across various integrated services and data sources. ## files - __init__.py | Marks the directory as a Python package for the widget subsystem. - builtin.py | Defines built-in widget kinds that don't require external services, providing their configurations, schemas, and validation. | exp: class:StaticConfig, func:get_builtin_widget_kind(kind: str) → WidgetKind | None, call:BUILTIN_WIDGET_KINDS.get, func:is_builtin_kind(kind: str) → bool, func:builtin_widget_kind_models() → dict[str, type], call:Field, func:validate_builtin_config(kind: str, config: dict[str, Any]) → dict[str, Any], call:builtin_widget_kind_models, call:models.get, call:dict, call:model_cls.model_validate(config or {}).model_dump | dep: typing, media_library_viewer_api.integrations.base, pydantic +- jellyseerr_stats.py | Fetches and caches request count statistics and recent requests from Jellyseerr for Jellyfin services. | exp: class:JellyseerrStatsProvider, method:__init__(self, ttl) → None, call:threading.Lock, method:fetch_stats(self, service: Any) → StatsResult, call:str, call:service.config.get, call:(service.secrets or {}).get, call:StatsResult, call:time.time, call:self._cache.get, call:_jellyseer_client, call:client.request_count, call:client.recent_requests, call:logger.warning, call:StatValue, call:int, call:counts.get, func:_jellyseer_client(cache_key: tuple[str, str, str]) → JellyseerrClient, call:JellyseerrClient | dep: logging, threading, time, functools, typing, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.widgets.stats_provider - prometheus_range.py | Provides shared helper functions for Prometheus range queries, including step-size derivation, window presets, and normalization of both Prometheus and Grafana API responses into a frontend-consumable series format. | exp: func:step_for_window(window_seconds: int, target_points) → int, call:max, call:round, func:_dedup_label(label: str, seen: dict[str, int]) → str, func:normalize_prometheus_matrix(result: list[dict[str, Any]]) → list[dict[str, Any]], call:entry.get, call:sorted, call:metric.items, call:str(k).startswith, call:_dedup_label, call:" ".join, call:_safe_int, call:points.append, call:_safe_float, call:series.append, func:normalize_grafana_frames(raw: dict[str, Any]) → list[dict[str, Any]], call:raw.get, call:results.items, call:ref_data.get, call:frame.get("data", {}).get, call:len, call:frame.get("schema", {}).get, call:value_field.get("config", {}).get, call:sorted, call:frame_labels.items, call:str(k).startswith, call:" ".join, call:_dedup_label, call:zip, call:_safe_int, call:points.append, call:_safe_float, call:series.append, func:_safe_float(raw: Any) → float | None, call:float, func:_safe_int(ts: Any) → int | None, call:int, call:float | dep: typing -- sources.py | Provides adapter classes that fetch and normalize data from various external services (Grafana, Alertmanager, Jellyfin, SSH) for dashboard widgets. | exp: class:ServiceRecord, class:WidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], class:BackupsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:build_backup_dashboard_summary, call:summary.model_dump, call:logger.exception, class:StaticWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:config.get, class:MetricSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:self._fetch_chart, call:self._fetch_gauge, call:self._fetch_mean, call:self._fetch_metric, call:logger.exception, method:_gateway_query(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, promql: str, window_seconds, max_data_points) → dict[str, Any], call:step_for_window, call:requests.post, call:resp.raise_for_status, call:resp.json, call:asyncio.wait_for, call:asyncio.to_thread, call:logger.exception, method:_fetch_chart(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._gateway_query, call:normalize_grafana_frames, method:_fetch_gauge(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:self._gateway_query, call:normalize_grafana_frames, call:len, method:_fetch_mean(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._gateway_query, call:normalize_grafana_frames, call:len, call:sum, method:_fetch_metric(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:self._gateway_query, call:normalize_grafana_frames, class:AlertmanagerWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:int, call:config.get, call:service.secrets.get, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:payload.get, call:isinstance, call:summarize_alerts, call:logger.exception, class:JellyfinWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:asyncio.wait_for, call:asyncio.to_thread, call:s.get("PlayState", {}).get, call:_map_sessions_to_activity_rows, call:logger.exception, class:SshTaskWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:config.get, call:store.get_task, call:task.get, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:_record_timeout, call:logger.exception, class:QbittorrentWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:_qbittorrent_client, call:asyncio.wait_for, call:asyncio.to_thread, call:data.get, call:torrents.values, call:t.get, call:by_state.get, call:len, call:server_state.get, call:time.time, call:QbittorrentSampleStore, call:store.append, call:store.window, call:logger.exception, func:build_service_record(store: SettingsStore, service_row: dict[str, Any]) → ServiceRecord, call:ServiceRecord, call:service_row.get, call:decrypt_secrets, call:bool, func:_record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) → None, call:get_settings_store, call:store.record_service_task_run, call:str, call:config.get, call:logger.exception, func:_qbittorrent_client(cache_key: tuple[str, str, str, str, int]) → QbittorrentClient, call:QbittorrentClient, func:get_service_adapter(service_type: str) → WidgetSource | None, call:SERVICE_ADAPTERS.get, func:get_builtin_adapter(kind: str) → WidgetSource | None, call:BUILTIN_ADAPTERS.get | dep: asyncio, logging, time, dataclasses, functools, typing, requests, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.domain.dashboard, media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.services.qbittorrent_store, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.prometheus_range, media_library_viewer_api.services.secrets +- sources.py | Adapts various service instances into dashboard widget data by fetching and normalizing metrics, alerts, media sessions, and task results. | exp: class:ServiceRecord, class:WidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], class:BackupsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:build_backup_dashboard_summary, call:summary.model_dump, call:logger.exception, class:StaticWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:config.get, class:MetricSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:self._fetch_chart, call:self._fetch_gauge, call:self._fetch_mean, call:self._fetch_metric, call:logger.exception, method:_gateway_query(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, promql: str, window_seconds, max_data_points) → dict[str, Any], call:step_for_window, call:requests.post, call:resp.raise_for_status, call:resp.json, call:asyncio.wait_for, call:asyncio.to_thread, call:logger.exception, method:_fetch_chart(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._gateway_query, call:normalize_grafana_frames, method:_fetch_gauge(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:self._gateway_query, call:normalize_grafana_frames, call:len, method:_fetch_mean(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._gateway_query, call:normalize_grafana_frames, call:len, call:sum, method:_fetch_metric(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:self._gateway_query, call:normalize_grafana_frames, class:AlertmanagerWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:int, call:config.get, call:service.secrets.get, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:payload.get, call:isinstance, call:summarize_alerts, call:logger.exception, class:JellyfinWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:asyncio.wait_for, call:asyncio.to_thread, call:s.get("PlayState", {}).get, call:_map_sessions_to_activity_rows, call:logger.exception, class:SshTaskWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:config.get, call:store.get_task, call:task.get, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:_record_timeout, call:logger.exception, class:QbittorrentWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:_qbittorrent_client, call:asyncio.wait_for, call:asyncio.to_thread, call:data.get, call:torrents.values, call:t.get, call:by_state.get, call:len, call:server_state.get, call:time.time, call:QbittorrentSampleStore, call:store.append, call:store.window, call:logger.exception, class:StatsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_stats_provider, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:logger.exception, call:str, call:config.get, call:next, func:build_service_record(store: SettingsStore, service_row: dict[str, Any]) → ServiceRecord, call:ServiceRecord, call:service_row.get, call:decrypt_secrets, call:bool, func:_record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) → None, call:get_settings_store, call:store.record_service_task_run, call:str, call:config.get, call:logger.exception, func:_qbittorrent_client(cache_key: tuple[str, str, str, str, int]) → QbittorrentClient, call:QbittorrentClient, func:get_service_adapter(service_type: str) → WidgetSource | None, call:SERVICE_ADAPTERS.get, func:get_builtin_adapter(kind: str) → WidgetSource | None, call:BUILTIN_ADAPTERS.get, func:get_stats_adapter() → WidgetSource | None | dep: asyncio, logging, time, dataclasses, functools, typing, requests, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.domain.dashboard, media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.services.qbittorrent_store, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets, media_library_viewer_api.widgets.prometheus_range, media_library_viewer_api.widgets.stats_provider, media_library_viewer_api.services.secrets, JellyfinClient, QbittorrentClient, summarize_alerts, SettingsStore, run_saved_task, normalize_grafana_frames +- stats_provider.py | Defines an abstract stats-provider interface and registry for services that expose named numeric metrics to be rendered by widgets. | exp: class:StatValue, class:StatsResult, class:StatsProvider, method:fetch_stats(self, service: Any) → StatsResult, func:register_stats_provider(service_type: str, provider: StatsProvider) → None, func:get_stats_provider(service_type: str) → StatsProvider | None, call:STATS_PROVIDERS.get | dep: dataclasses, typing ## arch -Adapter pattern with service-specific source classes normalizing heterogeneous API responses into a unified frontend-consumable series format, complemented by declarative schema-driven configuration for widget kinds. +Provider/adapter pattern with a stats-provider registry, built-in widget definitions with schema validation, and helper modules for external API integration (Jellyseerr, Prometheus/Grafana). ## tags -fetch, widget, call:, source, call:logger.exception, call:config.get, call:self., call:str +fetch, widget, stats, call:, call:str, call:self., source, call:logger.exception ## symbols - StaticConfig +- JellyseerrStatsProvider - ServiceRecord - WidgetSource - BackupsWidgetSource - StaticWidgetSource - MetricSource - AlertmanagerWidgetSource -- JellyfinWidgetSource ## workflows - change widgets behavior - read: __init__.py, builtin.py, prometheus_range.py + read: __init__.py, builtin.py, jellyseerr_stats.py ## dirty - diff --git a/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py b/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py new file mode 100644 index 0000000..aa186c3 --- /dev/null +++ b/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py @@ -0,0 +1,105 @@ +"""Jellyseerr stats provider — request counts + recent requests. + +Jellyseerr is an optional companion of the Jellyfin service (config lives on +the Jellyfin instance as ``jellyseerr_url`` / ``jellyseerr_api_key``). This +provider is registered for ``service_type == "jellyfin"`` and returns the +headline request stats (total / pending / approved / declined / processing / +available) plus a recent-requests list. + +To avoid several widgets + the Requests tab each hitting Jellyseerr, one +authenticated client is reused per service (lru_cache) and the stats result is +cached for a short TTL with a lock — the same pattern the qBittorrent client +uses to keep a single-threaded upstream from being hammered. +""" + +from __future__ import annotations + +import logging +import threading +import time +from functools import lru_cache +from typing import Any + +from media_library_viewer_api.clients.jellyseerr import JellyseerrClient +from media_library_viewer_api.widgets.stats_provider import ( + StatsResult, + StatValue, + register_stats_provider, +) + +logger = logging.getLogger(__name__) + +# Short-TTL cache: multiple widgets + the tab collapse onto one Jellyseerr fetch. +JS_STATS_CACHE_TTL = 10.0 + +# (stat key, display label) — order is the overview/grid order. +_JELLYSEERR_STATS: list[tuple[str, str]] = [ + ("total", "Total"), + ("pending", "Pending"), + ("approved", "Approved"), + ("declined", "Declined"), + ("processing", "Processing"), + ("available", "Available"), +] + + +@lru_cache(maxsize=16) +def _jellyseer_client(cache_key: tuple[str, str, str]) -> JellyseerrClient: + """Reuse one authenticated client per (service, url, api_key).""" + _service_id, base_url, api_key = cache_key + return JellyseerrClient(base_url, api_key) + + +class JellyseerrStatsProvider: + """StatsProvider backed by Jellyseerr /api/v1/request/count + /api/v1/request.""" + + def __init__(self, ttl: float = JS_STATS_CACHE_TTL) -> None: + self._ttl = ttl + self._cache: dict[str, tuple[float, StatsResult]] = {} + self._lock = threading.Lock() + + def fetch_stats(self, service: Any) -> StatsResult: + base_url = str(service.config.get("jellyseerr_url") or "") + # jellyseerr_api_key is migrating config -> secret; accept either during the transition. + api_key = str( + (service.secrets or {}).get("jellyseerr_api_key") + or service.config.get("jellyseerr_api_key") + or "" + ) + if not base_url or not api_key: + return StatsResult( + stats=[], + detail="Jellyseerr is not configured for this Jellyfin instance " + "(set jellyseerr_url + jellyseerr_api_key on the Jellyfin service).", + ) + + now = time.time() + with self._lock: + hit = self._cache.get(service.id) + if hit and (now - hit[0]) < self._ttl: + return hit[1] + + try: + client = _jellyseer_client((service.id, base_url, api_key)) + counts = client.request_count() + recent = client.recent_requests(20) + except Exception as exc: + logger.warning("Jellyseerr stats fetch failed for %s: %s", service.id, exc) + # Serve stale if we have it, else surface the error. + if hit: + return hit[1] + return StatsResult(stats=[], detail=f"Jellyseerr fetch failed: {exc}") + + result = StatsResult( + stats=[ + StatValue(key=key, label=label, value=int(counts.get(key, 0))) + for key, label in _JELLYSEERR_STATS + ], + recent=recent, + ) + with self._lock: + self._cache[service.id] = (time.time(), result) + return result + + +register_stats_provider("jellyfin", JellyseerrStatsProvider()) diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index d2ae15b..0289bc7 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -29,12 +29,14 @@ from media_library_viewer_api.integrations.alertmanager import summarize_alerts from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store from media_library_viewer_api.services.task_runner import run_saved_task +from media_library_viewer_api.widgets import jellyseerr_stats # noqa: F401 — registers the Jellyseerr stats provider from media_library_viewer_api.widgets.prometheus_range import ( WINDOW_PRESETS, normalize_grafana_frames, normalize_prometheus_matrix, # noqa: F401 — kept for future direct_url path (design decision 5) step_for_window, ) +from media_library_viewer_api.widgets.stats_provider import get_stats_provider logger = logging.getLogger(__name__) @@ -493,3 +495,49 @@ def get_service_adapter(service_type: str) -> WidgetSource | None: def get_builtin_adapter(kind: str) -> WidgetSource | None: return BUILTIN_ADAPTERS.get(kind) + + +class StatsWidgetSource: + """Generic source for ``stat`` and ``stats_overview`` widgets. + + Dispatches to the service type's registered :class:`StatsProvider`, so any + stats-provider service gets these two widget kinds for free. The widgets + router routes widget_kind in {"stat", "stats_overview"} here regardless of + service type. + """ + + async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: + if service is None: + return {"error": "Stats widget is missing its service"} + provider = get_stats_provider(service.service_type) + if provider is None: + return {"error": f"No stats provider for service type '{service.service_type}'"} + timeout = int(service.config.get("timeout_seconds") or 30) + try: + result = await asyncio.wait_for( + asyncio.to_thread(provider.fetch_stats, service), timeout=timeout + ) + except asyncio.TimeoutError: + return {"error": "Stats fetch timed out"} + except Exception as exc: + logger.exception("stats provider failed service=%s", service.id) + return {"error": f"Stats fetch failed: {exc}"} + if result.detail and not result.stats: + return {"error": result.detail} + if widget_kind == "stats_overview": + return { + "stats": [{"key": s.key, "label": s.label, "value": s.value} for s in result.stats], + "recent": result.recent, + } + stat_key = str(config.get("stat") or "") + match = next((s for s in result.stats if s.key == stat_key), None) + if match is None: + return {"error": f"Unknown stat '{stat_key}'"} + return {"key": match.key, "label": match.label, "value": match.value} + + +_STATS_ADAPTER = StatsWidgetSource() + + +def get_stats_adapter() -> WidgetSource | None: + return _STATS_ADAPTER diff --git a/backend/src/media_library_viewer_api/widgets/stats_provider.py b/backend/src/media_library_viewer_api/widgets/stats_provider.py new file mode 100644 index 0000000..14a2b33 --- /dev/null +++ b/backend/src/media_library_viewer_api/widgets/stats_provider.py @@ -0,0 +1,62 @@ +"""Generic stats-provider abstraction. + +A service type that exposes a set of named numeric metrics (Jellyseerr request +stats today; Sonarr/Radarr later) registers a :class:`StatsProvider`. The +widget layer renders the provider's output as either a single-stat widget (a +selector picks one metric) or a stats-overview grid. Keeping this behind a +small interface means future stats services reuse the same widgets + tab +without per-service widget kinds. + +Providers are synchronous (they do blocking HTTP) and are run in a thread by +the widget source / router. A provider should cache/de-duplicate fetches so +that several widgets + the tab don't each hit the upstream service. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + + +@dataclass(frozen=True) +class StatValue: + """One named metric.""" + + key: str + label: str + value: int | float + + +@dataclass +class StatsResult: + """Normalized output of a stats provider.""" + + stats: list[StatValue] + recent: list[dict[str, Any]] = field(default_factory=list) + # Optional human note (e.g. "not configured"); surfaced as an error when + # there are no stats. + detail: str | None = None + + +@runtime_checkable +class StatsProvider(Protocol): + """Return the current stats for a service instance. + + ``service`` is a duck-typed record with ``id``, ``service_type``, + ``config`` and ``secrets`` (see widgets.sources.ServiceRecord). + """ + + def fetch_stats(self, service: Any) -> StatsResult: ... + + +# Registry keyed by service_type. A service type with no provider simply has no +# stat widgets available. +STATS_PROVIDERS: dict[str, StatsProvider] = {} + + +def register_stats_provider(service_type: str, provider: StatsProvider) -> None: + STATS_PROVIDERS[service_type] = provider + + +def get_stats_provider(service_type: str) -> StatsProvider | None: + return STATS_PROVIDERS.get(service_type) diff --git a/backend/tests/test_jellyseerr_stats.py b/backend/tests/test_jellyseerr_stats.py new file mode 100644 index 0000000..d810380 --- /dev/null +++ b/backend/tests/test_jellyseerr_stats.py @@ -0,0 +1,117 @@ +"""Tests for the Jellyseerr stats provider + generic stat widget source.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock, patch + +from media_library_viewer_api.widgets.jellyseerr_stats import JellyseerrStatsProvider +from media_library_viewer_api.widgets.sources import ServiceRecord, StatsWidgetSource +from media_library_viewer_api.widgets.stats_provider import ( + StatValue, + get_stats_provider, +) + + +def _service( + *, + jellyseerr_url: str = "https://js.example.com", + jellyseerr_api_key: str = "key", +) -> ServiceRecord: + return ServiceRecord( + id="svc-jf", + service_type="jellyfin", + name="Jellyfin", + config={"jellyseerr_url": jellyseerr_url, "jellyseerr_api_key": jellyseerr_api_key}, + secrets={}, + enabled=True, + ) + + +_COUNTS = {"total": 5, "pending": 2, "approved": 1, "declined": 0, "processing": 1, "available": 1} +_RECENT = [{"id": 1, "name": "Inception", "status": "pending", "media_status": "available"}] + + +def test_provider_registered_for_jellyfin(): + assert isinstance(get_stats_provider("jellyfin"), JellyseerrStatsProvider) + + +def test_provider_returns_normalized_stats_and_recent(): + provider = JellyseerrStatsProvider(ttl=0) + fake = MagicMock() + fake.request_count.return_value = _COUNTS + fake.recent_requests.return_value = _RECENT + with patch("media_library_viewer_api.widgets.jellyseerr_stats._jellyseer_client", return_value=fake): + result = provider.fetch_stats(_service()) + assert [s.key for s in result.stats] == [ + "total", + "pending", + "approved", + "declined", + "processing", + "available", + ] + by_key = {s.key: s.value for s in result.stats} + assert by_key["pending"] == 2 and by_key["total"] == 5 + assert result.recent == _RECENT + + +def test_provider_not_configured_returns_detail(): + provider = JellyseerrStatsProvider() + result = provider.fetch_stats(_service(jellyseerr_url="", jellyseerr_api_key="")) + assert result.stats == [] + assert "not configured" in (result.detail or "").lower() + + +def test_provider_caches_within_ttl(): + provider = JellyseerrStatsProvider(ttl=10) + fake = MagicMock() + fake.request_count.return_value = _COUNTS + fake.recent_requests.return_value = _RECENT + with patch("media_library_viewer_api.widgets.jellyseerr_stats._jellyseer_client", return_value=fake): + provider.fetch_stats(_service()) + provider.fetch_stats(_service()) # served from cache + assert fake.request_count.call_count == 1 + + +def test_stat_widget_returns_selected_value(): + src = StatsWidgetSource() + result = MagicMock() + result.detail = None + result.stats = [StatValue("total", "Total", 5), StatValue("pending", "Pending", 2)] + result.recent = [] + provider = MagicMock() + provider.fetch_stats.return_value = result + with patch("media_library_viewer_api.widgets.sources.get_stats_provider", return_value=provider): + data = asyncio.run(src.fetch(_service(), "stat", {"stat": "pending"})) + assert data == {"key": "pending", "label": "Pending", "value": 2} + + +def test_stats_overview_widget_returns_all_stats_and_recent(): + src = StatsWidgetSource() + result = MagicMock() + result.detail = None + result.stats = [StatValue("total", "Total", 5), StatValue("pending", "Pending", 2)] + result.recent = _RECENT + provider = MagicMock() + provider.fetch_stats.return_value = result + with patch("media_library_viewer_api.widgets.sources.get_stats_provider", return_value=provider): + data = asyncio.run(src.fetch(_service(), "stats_overview", {})) + assert data["stats"] == [ + {"key": "total", "label": "Total", "value": 5}, + {"key": "pending", "label": "Pending", "value": 2}, + ] + assert data["recent"] == _RECENT + + +def test_stat_widget_unknown_stat_returns_error(): + src = StatsWidgetSource() + result = MagicMock() + result.detail = None + result.stats = [StatValue("total", "Total", 5)] + result.recent = [] + provider = MagicMock() + provider.fetch_stats.return_value = result + with patch("media_library_viewer_api.widgets.sources.get_stats_provider", return_value=provider): + data = asyncio.run(src.fetch(_service(), "stat", {"stat": "nope"})) + assert "error" in data diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 4057e04..9034bd1 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -101,7 +101,12 @@ def test_authentik_service_definition(): def test_definitions_declare_widget_kinds(): assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"} assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} - assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"} + assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == { + "activity", + "now_playing", + "stat", + "stats_overview", + } assert get_service_definition("nextcloud").widget_kinds == [] assert get_service_definition("authentik").widget_kinds == [] assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}