From 6d46de26c4a464317937c214eb94d6ec38f95645 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 11 Jul 2026 13:07:08 +0000 Subject: [PATCH] fix(qbittorrent): stop mislabeling gateway/URL errors as auth failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential tester always reported "Authentication failed — qBittorrent rejected the credentials" for the qBittorrent service, even when credentials were correct. test_connection classified any RuntimeError whose message contained "login failed" as an auth failure — and the gateway-timeout error (502/503/504 from the reverse proxy) and the wrong-URL diagnostic both started with "qBittorrent login failed:", so a proxy timeout was reported as a credentials rejection. That sent users down the wrong path (re-entering correct passwords to fix a 504). - QbittorrentClient._login: gateway and URL/routing errors no longer contain "login failed"; only a genuine "Fails." body carries the "invalid username or password" signal. - integrations/qbittorrent.test_connection: key the auth message off "invalid username or password" specifically; all other login errors flow through translate_connection_error so the real reason (proxy timeout, wrong URL, empty body) is surfaced. After this, a failing test reports the actual cause (e.g. "qBittorrent is unreachable: reverse proxy returned HTTP 504 ...") instead of accusing the credentials. New regression test asserts a gateway error is NOT reported as "Authentication failed". 386/386 backend tests pass; ruff clean. --- .../clients/.pi-map.index.md | 2 +- .../clients/.pi-map.md | 6 ++--- .../clients/qbittorrent.py | 8 +++---- .../integrations/.pi-map.index.md | 2 +- .../integrations/.pi-map.md | 22 +++++++++---------- .../integrations/qbittorrent.py | 4 +++- backend/tests/test_credential_tester.py | 18 ++++++++++++++- 7 files changed, 40 insertions(+), 22 deletions(-) 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 b69739c..958643d 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 (API wrappers and command executors) for media servers, identity providers, torrent clients, and remote/local filesystems. +Provides external service integration clients (API wrappers and remote/local command executors) for media library data aggregation and system monitoring. ## 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 7b96c5b..ff794bd 100644 --- a/backend/src/media_library_viewer_api/clients/.pi-map.md +++ b/backend/src/media_library_viewer_api/clients/.pi-map.md @@ -4,7 +4,7 @@ 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 (API wrappers and command executors) for media servers, identity providers, torrent clients, and remote/local filesystems. +Provides external service integration clients (API wrappers and remote/local command executors) for media library data aggregation and system monitoring. ## 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 @@ -12,10 +12,10 @@ Provides external service integration clients (API wrappers and command executor - 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 - 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 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 +- qbittorrent.py | Minimal read-only qBittorrent Web API client that manages authenticated session cookies to fetch torrent 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 -Adapter/wrapper pattern with each client encapsulating service-specific communication (HTTP REST, SSH, local shell) behind a uniform interface returning plain Python data structures. +Client-wrapper pattern with per-service classes encapsulating authentication, pagination, and response normalization; consistent dictionary-based data contracts across clients. ## tags call:logger.info, error, call:logger.debug, call:self.get, client, init, timeout, call:logger.warning ## symbols diff --git a/backend/src/media_library_viewer_api/clients/qbittorrent.py b/backend/src/media_library_viewer_api/clients/qbittorrent.py index 03011cd..8697215 100644 --- a/backend/src/media_library_viewer_api/clients/qbittorrent.py +++ b/backend/src/media_library_viewer_api/clients/qbittorrent.py @@ -68,7 +68,7 @@ class QbittorrentClient: # 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"qBittorrent is unreachable: 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." ) @@ -85,9 +85,9 @@ class QbittorrentClient: if body == "Fails.": raise RuntimeError(f"qBittorrent login failed (HTTP {resp.status_code}): invalid username or password") raise RuntimeError( - f"qBittorrent login failed (HTTP {resp.status_code}, body={body!r}). " - "Expected the text 'Ok.' from /api/v2/auth/login — this usually means " - "base_url does not reach the qBittorrent Web API (check the URL, path, " + f"Unexpected response from qBittorrent login endpoint (HTTP {resp.status_code}, " + f"body={body!r}). Expected the text 'Ok.' from /api/v2/auth/login — this usually " + "means base_url does not reach the qBittorrent Web API (check the URL, path, " "and any reverse proxy in front of qBittorrent)." ) 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 956b644..75bb825 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 -Defines external service integrations (e.g., Jellyfin, Prometheus, Alertmanager) with configuration models, widget schemas, and data-fetching logic for the media library viewer API. +Provides a plugin-based integration layer connecting the media library viewer API to various external services (Alertmanager, Authentik, Jellyfin, Prometheus, qBittorrent, etc.) via a centralized registry. ## 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 4292c71..f353c3a 100644 --- a/backend/src/media_library_viewer_api/integrations/.pi-map.md +++ b/backend/src/media_library_viewer_api/integrations/.pi-map.md @@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api/integrations index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md ## role -Defines external service integrations (e.g., Jellyfin, Prometheus, Alertmanager) with configuration models, widget schemas, and data-fetching logic for the media library viewer API. +Provides a plugin-based integration layer connecting the media library viewer API to various external services (Alertmanager, Authentik, Jellyfin, Prometheus, qBittorrent, etc.) via a centralized registry. ## files - __init__.py | Defines a closed registry module for service integrations. -- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | 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 | dep: typing, media_library_viewer_api.integrations.base -- authentik.py | Defines the service configuration model and definition for integrating Authentik as a user directory and identity provider. | exp: class:AuthentikConfig | dep: media_library_viewer_api.integrations.base +- 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 foundational base classes and dataclasses for defining external service integrations, including config validation and widget schema generation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, 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 | dep: dataclasses, typing, pydantic -- jellyfin.py | Defines the Jellyfin media server service configuration, secret fields, and widget definitions for activity and now-playing sessions. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig | dep: media_library_viewer_api.integrations.base -- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base -- prometheus.py | Defines the Prometheus service configuration and widget types (metric, chart, gauge, mean) for querying and visualizing PromQL data. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig | dep: media_library_viewer_api.integrations.base -- qbittorrent.py | Declares the qBittorrent service definition including config models, secret fields, and three widget kinds (totals, active, speed). | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig | 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 +- 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 a Prometheus service integration that queries metrics through a Grafana gateway and provides configuration 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 config models, connection testing, and widget definitions for displaying torrent activity and speeds. | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig, 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 configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base +- 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 -Plugin-style registry pattern with a shared base class hierarchy; each service module independently defines config dataclasses, widget types, and summarization logic, all registered in a central closed registry for discovery and lookup. +Registry pattern with a shared base class defining config schemas, secrets, widgets, and connection-testing contracts; each integration is a self-contained module registered in a closed registry for runtime discovery. ## tags -config, widget, service, integrations, base, media_library_viewer_api, prometheus, definition +config, connection, widget, media_library_viewer_api, service, error, integrations, call:str ## symbols - AlertmanagerConfig - AlertmanagerAlertsWidgetConfig diff --git a/backend/src/media_library_viewer_api/integrations/qbittorrent.py b/backend/src/media_library_viewer_api/integrations/qbittorrent.py index 99f93e7..f620a79 100644 --- a/backend/src/media_library_viewer_api/integrations/qbittorrent.py +++ b/backend/src/media_library_viewer_api/integrations/qbittorrent.py @@ -42,8 +42,10 @@ def test_connection( return TestResult(ok=True, detail="Connected to qBittorrent.", evidence=version) except RuntimeError as exc: lowered = str(exc).lower() - if "login failed" in lowered: + if "invalid username or password" in lowered: return TestResult(ok=False, detail="Authentication failed — qBittorrent rejected the credentials.") + # Gateway timeout, wrong URL/path, empty body, etc. — surface the real + # reason instead of masking every login error as an auth failure. return translate_connection_error(exc, context="qBittorrent") except Exception as exc: return translate_connection_error(exc, context="qBittorrent") diff --git a/backend/tests/test_credential_tester.py b/backend/tests/test_credential_tester.py index c4272e3..1a834ce 100644 --- a/backend/tests/test_credential_tester.py +++ b/backend/tests/test_credential_tester.py @@ -76,12 +76,28 @@ class TestQbittorrentTestConnection: def test_login_failed_translates_to_auth_message(self) -> None: mock_client = MagicMock() - mock_client.maindata.side_effect = RuntimeError("qBittorrent login failed: Fails.") + # Mirrors the real _login auth-failure message for a "Fails." body. + mock_client.maindata.side_effect = RuntimeError( + "qBittorrent login failed (HTTP 200): invalid username or password" + ) with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client): result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock()) assert result.ok is False assert "Authentication failed" in result.detail + def test_gateway_error_does_not_masquerade_as_auth_failure(self) -> None: + mock_client = MagicMock() + # A 504 from the reverse proxy must NOT be reported as "Authentication + # failed" — that misled users into re-entering correct credentials. + mock_client.maindata.side_effect = RuntimeError( + "qBittorrent is unreachable: reverse proxy returned HTTP 504 for http://qb:8080/api/v2/auth/login." + ) + with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client): + result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock()) + assert result.ok is False + assert "Authentication failed" not in result.detail + assert "504" in result.detail + def test_connection_error_translates(self) -> None: mock_client = MagicMock() mock_client.maindata.side_effect = requests.ConnectionError("Connection refused")