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:
@@ -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:
|
||||
|
||||
@@ -73,9 +73,12 @@ def test_registry_contains_eight_service_types():
|
||||
def test_jellyseerr_absorbed_into_jellyfin():
|
||||
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
|
||||
assert "jellyseerr" not in SERVICE_DEFINITIONS
|
||||
jellyfin_config = get_service_definition("jellyfin").config_schema["properties"]
|
||||
jellyfin = get_service_definition("jellyfin")
|
||||
jellyfin_config = jellyfin.config_schema["properties"]
|
||||
assert "jellyseerr_url" in jellyfin_config
|
||||
assert "jellyseerr_api_key" in jellyfin_config
|
||||
# jellyseerr_api_key moved from config to a secret field.
|
||||
assert "jellyseerr_api_key" not in jellyfin_config
|
||||
assert "jellyseerr_api_key" in {sf.key for sf in jellyfin.secret_fields}
|
||||
|
||||
|
||||
def test_backups_service_definition():
|
||||
@@ -555,10 +558,42 @@ def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
|
||||
# Jellyseerr row is gone.
|
||||
assert store.list_services("jellyseerr") == []
|
||||
|
||||
# Jellyfin config gained the absorbed fields.
|
||||
# Jellyfin config gained jellyseerr_url; the api key is now a secret.
|
||||
migrated = store.get_service(jellyfin["id"])
|
||||
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
|
||||
assert migrated["config"]["jellyseerr_api_key"] == "js-key"
|
||||
assert "jellyseerr_api_key" not in migrated["config"]
|
||||
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "js-key"
|
||||
# The existing Jellyfin api_key is preserved (not double-encrypted).
|
||||
assert decrypt_value(migrated["secrets"]["api_key"]) == "jf-key"
|
||||
|
||||
|
||||
def test_jellyseerr_api_key_migrates_from_config_to_secret(tmp_path):
|
||||
"""A pre-existing plaintext jellyseerr_api_key in config moves to a secret."""
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
jellyfin = store.upsert_service(
|
||||
{
|
||||
"service_type": "jellyfin",
|
||||
"name": "Main Jellyfin",
|
||||
"config": {
|
||||
"base_url": "https://jellyfin.example.com",
|
||||
"jellyseerr_url": "https://jellyseerr.example.com",
|
||||
"jellyseerr_api_key": "plaintext-key", # legacy plaintext in config
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
secret_values={"api_key": "jf-key"},
|
||||
)
|
||||
|
||||
store.ensure_defaults() # runs the config->secret migration
|
||||
|
||||
migrated = store.get_service(jellyfin["id"])
|
||||
assert "jellyseerr_api_key" not in migrated["config"]
|
||||
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
|
||||
# Idempotent: a second run keeps it in secrets, doesn't wipe it.
|
||||
store.ensure_defaults()
|
||||
migrated = store.get_service(jellyfin["id"])
|
||||
assert decrypt_value(migrated["secrets"]["jellyseerr_api_key"]) == "plaintext-key"
|
||||
|
||||
|
||||
def test_jellyseerr_dropped_when_no_jellyfin(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user