feat(jellyseer): move jellyseerr_api_key to an encrypted secret (slice 2/3)

The Jellyseerr API key was stored as plaintext in the Jellyfin service config.
It is now a SecretField on the Jellyfin service, so it is encrypted at rest and
rendered as a masked secret input (the generic config editor stops exposing
it, and the secret editor picks it up automatically).

Migration (idempotent, runs in ensure_defaults):
- _migrate_jellyseerr_api_key_to_secret: for every Jellyfin service with a
  plaintext jellyseerr_api_key still in config, encrypt it ONCE into the
  secrets blob (direct UPDATE so existing encrypted secrets are preserved, not
  re-encrypted) and remove it from config.
- _migrate_jellyseerr_into_jellyfin: standalone-jellyseerr absorption now
  stores the key as a secret, and decrypts the Jellyfin api_key before handing
  it to upsert_service (fixes a pre-existing double-encrypt on that rare path).

The stats provider already reads jellyseerr_api_key from secrets-or-config, so
it works before, during, and after the migration.

Tests: absorbed-key lands in secrets (and existing api_key isn't corrupted);
new plaintext-config -> secret migration + idempotency. 401/401 backend pass.
This commit is contained in:
Developer
2026-07-12 13:40:56 +00:00
parent b8cb29e330
commit e25240c2f3
7 changed files with 107 additions and 20 deletions
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/integrations
## role
Provides pluggable external service integrations with standardized configuration, connection testing, and widget definitions for the media library viewer API.
Provides a pluggable integration layer for connecting to and monitoring external self-hosted services (e.g., Jellyfin, Prometheus, qBittorrent, Nextcloud) with unified config schemas, connection testing, and widget definitions.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.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 with standardized configuration, connection testing, and widget definitions for the media library viewer API.
Provides a pluggable integration layer for connecting to and monitoring external self-hosted services (e.g., Jellyfin, Prometheus, qBittorrent, Nextcloud) with unified config schemas, connection testing, and widget definitions.
## 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 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
- jellyfin.py | Defines the Jellyfin service integration configuration, connection testing, and widget definitions 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
Plugin/registry pattern with abstract base classes defining contracts for config schemas, secrets, widgets, and connection testing, discovered through a closed central registry.
Registry-based plugin pattern with a shared base class defining standard interfaces (config models, secrets, widgets, connection tests) that each service integration implements and registers with a central closed registry for dynamic discovery.
## tags
config, connection, widget, media_library_viewer_api, service, error, integrations, test
## symbols
@@ -40,18 +40,16 @@ def test_connection(
class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config.
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
paired Jellyseerr companion config, absorbed from the former standalone
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
When both are set, the Jellyfin service page renders a Requests tab backed
by Jellyseerr.
The optional ``jellyseerr_url`` field pairs a Jellyseerr companion with this
Jellyfin instance; the matching ``jellyseerr_api_key`` is a secret field on
the service. When both are set, the Jellyfin service page renders a Requests
tab backed by Jellyseerr.
"""
base_url: ServiceBaseUrl
user_id: str = ""
timeout_seconds: int = 60
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
class JellyfinActivityWidgetConfig(WidgetConfigBase):
@@ -93,6 +91,12 @@ DEFINITION = ServiceDefinition(
config_model=JellyfinConfig,
secret_fields=[
SecretField(key="api_key", label="API key", required=True),
SecretField(
key="jellyseerr_api_key",
label="Jellyseerr API key",
required=False,
helper="Enables the Requests tab + request-stats widgets (optional).",
),
],
widget_kinds=[
widget_kind(
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/services
## role
Backend service layer providing business logic for media indexing, backup monitoring, email delivery, secrets management, task execution, and persistent storage operations.
Backend service layer providing business logic for backup monitoring, media indexing, email delivery, task execution, secrets management, and persistent data storage.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
File diff suppressed because one or more lines are too long
@@ -448,6 +448,42 @@ class SettingsStore:
if not row or int(row[0]) == 0:
self._seed_local_machine()
self._migrate_jellyseerr_into_jellyfin()
self._migrate_jellyseerr_api_key_to_secret()
def _migrate_jellyseerr_api_key_to_secret(self) -> None:
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets.
The key was originally a plaintext config field; it is now a secret.
Idempotent: once no Jellyfin config carries the key this is a no-op. Uses
a direct UPDATE so existing (encrypted) secrets are preserved untouched
rather than re-encrypted.
"""
from media_library_viewer_api.services.secrets import encrypt_value
self.init_schema()
moved = 0
for row in self.list_services("jellyfin"):
config = dict(row.get("config") or {})
plaintext = str(config.get("jellyseerr_api_key") or "").strip()
if not plaintext:
continue
secrets_blob = dict(row.get("secrets") or {})
if "jellyseerr_api_key" not in secrets_blob:
secrets_blob["jellyseerr_api_key"] = encrypt_value(plaintext)
config.pop("jellyseerr_api_key", None)
with self.connect() as conn:
conn.execute(
"UPDATE services SET config_json = ?, secrets_json = ?, updated_at = ? WHERE id = ?",
(json.dumps(config), json.dumps(secrets_blob), int(time.time()), row["id"]),
)
conn.commit()
moved += 1
logger.info(
"migrated jellyseerr_api_key config->secret for jellyfin service %r",
row["name"],
)
if moved:
logger.info("migrated jellyseerr_api_key to secret for %s jellyfin service(s)", moved)
def _migrate_jellyseerr_into_jellyfin(self) -> None:
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
@@ -493,9 +529,18 @@ class SettingsStore:
break
if target:
# list_services returns the stored (encrypted) secrets blob, so
# decrypt the existing Jellyfin api_key before handing it back to
# upsert_service (which re-encrypts) — otherwise it double-encrypts.
target_api_key = str(target["secrets"].get("api_key") or "")
if target_api_key:
try:
target_api_key = decrypt_value(target_api_key)
except Exception:
logger.warning("could not decrypt jellyfin api_key for %r", target["name"])
target_api_key = ""
merged_config = dict(target["config"])
merged_config["jellyseerr_url"] = js_url
merged_config["jellyseerr_api_key"] = js_api_key
self.upsert_service(
{
"id": target["id"],
@@ -504,7 +549,10 @@ class SettingsStore:
"config": merged_config,
"enabled": target["enabled"],
},
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
secret_values={
"api_key": target_api_key,
"jellyseerr_api_key": js_api_key,
},
)
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
else: