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 090acfc..b69739c 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 layer with read-only API client wrappers and remote/local execution helpers for aggregating data from media, directory, torrent, and authentication services. +Provides external service integration clients (API wrappers and command executors) for media servers, identity providers, torrent clients, and remote/local filesystems. ## 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 0e04645..7b96c5b 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 layer with read-only API client wrappers and remote/local execution helpers for aggregating data from media, directory, torrent, and authentication services. +Provides external service integration clients (API wrappers and command executors) for media servers, identity providers, torrent clients, and remote/local filesystems. ## 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 | Builds decoupled (connect, read) timeout tuples for the `requests` library to allow short connect times with generous read budgets. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float +- 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 that fetches user data and metadata to 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 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 - 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 | Provides a minimal read-only API client for fetching sync data from a qBittorrent Web API with transparent re-authentication. | 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, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:bool, call:resp.cookies.get, call:logger.info, 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:self._get | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout +- qbittorrent.py | Minimal read-only API client for qBittorrent that handles authenticated session management to fetch sync data. | 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, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:bool, call:resp.cookies.get, call:logger.info, 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:self._get | dep: logging, 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 -Collection of decoupled HTTP client wrappers using the `requests` library with shared timeout configuration, alongside a command-execution abstraction (SSH/local) that unifies remote and local filesystem operations under a common interface. +Adapter/wrapper pattern with each client encapsulating service-specific communication (HTTP REST, SSH, local shell) behind a uniform interface returning plain Python data structures. ## tags -call:logger.info, error, call:logger.debug, call:self.get, client, init, call:logger.warning, call:shlex.quote +call:logger.info, error, call:logger.debug, call:self.get, client, init, timeout, call:logger.warning ## symbols - AuthentikClient - JellyfinClient diff --git a/backend/src/media_library_viewer_api/clients/qbittorrent.py b/backend/src/media_library_viewer_api/clients/qbittorrent.py index a54d4f3..03011cd 100644 --- a/backend/src/media_library_viewer_api/clients/qbittorrent.py +++ b/backend/src/media_library_viewer_api/clients/qbittorrent.py @@ -61,6 +61,17 @@ class QbittorrentClient: timeout=self.timeout, headers={"Referer": self.base_url}, ) + # 502/503/504 come from the reverse proxy when qBittorrent is down, + # starting up, or can't answer within the proxy's forwarding timeout + # (qBittorrent's PBKDF2 password check is intentionally slow, so a + # flood of concurrent logins can trip this). Surface it clearly rather + # than as a bare HTTPError. + if resp.status_code in (502, 503, 504): + raise RuntimeError( + f"qBittorrent login failed: reverse proxy returned HTTP {resp.status_code} " + f"for {resp.url}. qBittorrent may be down, starting up, or unable to " + "answer within the proxy's forwarding timeout." + ) resp.raise_for_status() body = resp.text.strip() # Some reverse proxies forward the SID cookie but mangle the text body; 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 c95338a..c33159a 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 -Widget subsystem providing configurable dashboard widget definitions, source adapters, and data transformation helpers for the media library viewer API. +Provides widget data adapters and configuration definitions that fetch, normalize, and validate content from both built-in and external services for dashboard display. ## 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/widgets/.pi-map.md b/backend/src/media_library_viewer_api/widgets/.pi-map.md index 374634a..07fb80d 100644 --- a/backend/src/media_library_viewer_api/widgets/.pi-map.md +++ b/backend/src/media_library_viewer_api/widgets/.pi-map.md @@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api/widgets index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md ## role -Widget subsystem providing configurable dashboard widget definitions, source adapters, and data transformation helpers for the media library viewer API. +Provides widget data adapters and configuration definitions that fetch, normalize, and validate content from both built-in and external services for dashboard display. ## 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 -- prometheus_range.py | Provides shared helper functions for Prometheus range queries, including step-size derivation and matrix/series normalization for frontend chart rendering. | exp: func:step_for_window(window_seconds: int, target_points) → int, call:max, call:round, 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:" ".join, 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 widget source adapters that translate widget instances into dashboard data by fetching and transforming data from various services (Prometheus, Alertmanager, Jellyfin, qBittorrent, SSH tasks) or built-in sources (backups, static). | 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:PrometheusWidgetSource, 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:self._fetch_chart, call:self._fetch_gauge, call:self._fetch_mean, call:self._instant_query, call:config.get, call:logger.exception, method:_range_query(self, base_url: str, timeout: int, promql: str, window: int) → dict[str, Any], call:step_for_window, call:int, call:time.time, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:logger.exception, call:payload.get("data", {}).get, method:_instant_query(self, base_url: str, timeout: int, promql: str) → dict[str, Any], call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:logger.exception, call:payload.get, method:_fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._range_query, call:normalize_prometheus_matrix, method:_fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:self._instant_query, call:config.get, call:raw["result"].get, call:len, call:float, method:_fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._range_query, call:len, call:result[0].get, call:nums.append, call:float, call:sum, 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:QbittorrentClient, 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: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, 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, JellyfinClient, QbittorrentClient, SettingsStore, summarize_alerts, run_saved_task, decrypt_secrets, normalize_prometheus_matrix +- 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 ## arch -Adapter pattern with modular source connectors per service, built-in widget registry/schema definitions, and shared utility functions for data normalization and frontend rendering. +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. ## tags -widget, fetch, call:logger.exception, source, call:config.get, call:int, call:self., builtin +fetch, widget, call:, source, call:logger.exception, call:config.get, call:self., call:str ## symbols - StaticConfig - ServiceRecord - WidgetSource - BackupsWidgetSource - StaticWidgetSource -- PrometheusWidgetSource +- MetricSource - AlertmanagerWidgetSource - JellyfinWidgetSource ## workflows diff --git a/backend/src/media_library_viewer_api/widgets/sources.py b/backend/src/media_library_viewer_api/widgets/sources.py index a26fb40..d2ae15b 100644 --- a/backend/src/media_library_viewer_api/widgets/sources.py +++ b/backend/src/media_library_viewer_api/widgets/sources.py @@ -14,6 +14,7 @@ import asyncio import logging import time from dataclasses import dataclass, field +from functools import lru_cache from typing import Any, Protocol import requests @@ -386,6 +387,20 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo logger.exception("failed to record ssh task timeout") +@lru_cache(maxsize=16) +def _qbittorrent_client(cache_key: tuple[str, str, str, str, int]) -> QbittorrentClient: + """Build (or reuse) a qBittorrent client for a service. + + Cached per (service_id, base_url, username, password, timeout) so the + authenticated session/SID cookie persists across widget fetches. A + credentials or URL change produces a new cache key, so stale clients are + not reused after reconfiguration. Mirrors the Jellyfin client cache in + dependencies._jellyfin_client_for. + """ + _service_id, base_url, username, password, timeout = cache_key + return QbittorrentClient(base_url, username, password, timeout=timeout) + + class QbittorrentWidgetSource: """Fetch qBittorrent data for totals, active, and speed widgets.""" @@ -400,7 +415,13 @@ class QbittorrentWidgetSource: if not base_url or not username or not password: return {"error": "qBittorrent service is missing base_url, username, or password"} - client = QbittorrentClient(base_url, username, password, timeout) + # Reuse one authenticated client per service so the SID cookie + # persists across fetches and we don't re-login on every widget + # poll. qBittorrent verifies passwords with slow PBKDF2 hashing, + # so logging in on every fetch (3 widgets x frequent polls) + # saturates its web thread pool and the reverse proxy returns 504 + # gateway timeouts. The client re-logins itself on a 403. + client = _qbittorrent_client((service.id, base_url, username, password, timeout)) data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout) server_state = data.get("server_state", {}) torrents = data.get("torrents", {}) diff --git a/backend/tests/test_qbittorrent_client.py b/backend/tests/test_qbittorrent_client.py index fb78ec7..a2c8ca0 100644 --- a/backend/tests/test_qbittorrent_client.py +++ b/backend/tests/test_qbittorrent_client.py @@ -174,6 +174,21 @@ class QbittorrentClientTests(unittest.TestCase): self.assertIn("HTTP 200", message) self.assertIn("base_url", message) + def test_login_gateway_error_is_diagnostic(self) -> None: + """A 502/503/504 from the reverse proxy surfaces a clear gateway message.""" + resp = MagicMock() + resp.status_code = 504 + resp.text = "" + resp.cookies = {} + self.session.post.return_value = resp + + with self.assertRaises(RuntimeError) as ctx: + self.client._login() + + message = str(ctx.exception) + self.assertIn("HTTP 504", message) + self.assertIn("reverse proxy", message.lower()) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_widgets.py b/backend/tests/test_widgets.py index c176911..36b87f9 100644 --- a/backend/tests/test_widgets.py +++ b/backend/tests/test_widgets.py @@ -30,6 +30,22 @@ def _encryption_key(monkeypatch): yield +@pytest.fixture(autouse=True) +def _clear_qbittorrent_client_cache(): + """Reset the per-service qBittorrent client cache between tests. + + QbittorrentWidgetSource reuses one authenticated client per service + (lru_cache) so the SID cookie persists across fetches. Without clearing, + a mock client cached by one test would leak into later tests that patch + QbittorrentClient differently. + """ + from media_library_viewer_api.widgets.sources import _qbittorrent_client + + _qbittorrent_client.cache_clear() + yield + _qbittorrent_client.cache_clear() + + @pytest.fixture def client(tmp_path): """FastAPI test client with a fresh settings store and auth disabled."""