From e25240c2f3fd98ed6bcf6dd685f058a2e5318ac8 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 12 Jul 2026 13:40:56 +0000 Subject: [PATCH] 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. --- .../integrations/.pi-map.index.md | 2 +- .../integrations/.pi-map.md | 6 +-- .../integrations/jellyfin.py | 16 +++--- .../services/.pi-map.index.md | 2 +- .../services/.pi-map.md | 6 +-- .../services/settings_store.py | 52 ++++++++++++++++++- backend/tests/test_services.py | 43 +++++++++++++-- 7 files changed, 107 insertions(+), 20 deletions(-) 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 2f9c7b9..36edd72 100644 --- a/backend/src/media_library_viewer_api/integrations/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/integrations/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/integrations ## role -Provides pluggable external service integrations 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 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 07e5a30..1eb26ee 100644 --- a/backend/src/media_library_viewer_api/integrations/.pi-map.md +++ b/backend/src/media_library_viewer_api/integrations/.pi-map.md @@ -4,21 +4,21 @@ dir: backend/src/media_library_viewer_api/integrations index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md ## role -Provides pluggable external service integrations 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 diff --git a/backend/src/media_library_viewer_api/integrations/jellyfin.py b/backend/src/media_library_viewer_api/integrations/jellyfin.py index 06b2f1b..2a5c653 100644 --- a/backend/src/media_library_viewer_api/integrations/jellyfin.py +++ b/backend/src/media_library_viewer_api/integrations/jellyfin.py @@ -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( diff --git a/backend/src/media_library_viewer_api/services/.pi-map.index.md b/backend/src/media_library_viewer_api/services/.pi-map.index.md index 06fab22..025c890 100644 --- a/backend/src/media_library_viewer_api/services/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/services/.pi-map.index.md @@ -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 diff --git a/backend/src/media_library_viewer_api/services/.pi-map.md b/backend/src/media_library_viewer_api/services/.pi-map.md index e9b3bdf..41717ec 100644 --- a/backend/src/media_library_viewer_api/services/.pi-map.md +++ b/backend/src/media_library_viewer_api/services/.pi-map.md @@ -4,7 +4,7 @@ dir: backend/src/media_library_viewer_api/services index: backend/src/media_library_viewer_api/services/.pi-map.index.md ## 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. ## files - __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh - backup_alert_engine.py | Generates alerts for backup job runs based on failure status, size/duration anomalies compared to historical medians, and missed schedules. | exp: func:generate_alerts_for_run(run: dict[str, Any], previous_runs: list[dict[str, Any]], job: dict[str, Any] | None) → list[dict[str, Any]], call:alerts.append, call:run.get, call:r.get, call:len, call:statistics.median, func:check_missed_schedules(jobs: list[dict[str, Any]], get_latest_run: callable, existing_alerts: list[dict[str, Any]]) → list[dict[str, Any]], call:int, call:time.time, call:job.get, call:get_latest_run, call:alerts.append, call:any | dep: statistics, typing, time @@ -20,11 +20,11 @@ Backend service layer providing business logic for media indexing, backup monito - secrets.py | Provides symmetric authenticated encryption for service secrets using Fernet with a mandatory environment variable master key. | exp: class:EncryptionKeyError, func:get_encryption_key() → bytes, call:os.environ.get, call:raw.strip().encode, call:Fernet, raise:EncryptionKeyError, func:reset_encryption_key_cache() → None, call:get_encryption_key.cache_clear, func:_fernet() → Fernet, call:Fernet, call:get_encryption_key, func:encrypt_value(plaintext: str) → str, call:_fernet().encrypt(plaintext.encode()).decode, call:plaintext.encode, func:decrypt_value(ciphertext: str) → str, call:_fernet().decrypt(ciphertext.encode()).decode, call:ciphertext.encode, raise:EncryptionKeyError, func:encrypt_secrets(values: dict[str, str]) → dict[str, str], call:_fernet, call:fernet.encrypt(value.encode()).decode, call:value.encode, call:values.items, func:decrypt_secrets(blob: dict[str, str]) → dict[str, str], call:_fernet, call:blob.items, call:fernet.decrypt(ciphertext.encode()).decode, call:ciphertext.encode, raise:EncryptionKeyError, func:generate_development_key() → str, call:Fernet.generate_key().decode, func:validate_encryption_key() → None, call:get_encryption_key | dep: os, functools, cryptography.fernet, functools.lru_cache - service_data.py | Manages the lifecycle (provisioning, migration, cascade-delete) of per-concern SQLite databases for service-owned persistent data via a singleton harness. | exp: class:StorageConcern, class:ServiceDataHarness, method:__init__(self, base_dir: Path | str) → None, call:Path, method:register(self, concern: StorageConcern) → None, method:db_path(self, concern_key: str) → Path, method:connect(self, concern_key: str) → sqlite3.Connection, call:self.db_path, call:path.parent.mkdir, call:sqlite3.connect, call:conn.execute, method:run_migrations(self) → None, call:self._concerns.values, call:self.db_path, call:path.parent.mkdir, call:sqlite3.connect, call:conn.execute, call:s.strip, call:migration_sql.split, call:str(exc).lower, call:logger.debug, call:conn.commit, call:conn.close, method:cascade_delete(self, service_id: str) → None, call:self._concerns.values, call:self.db_path, call:path.exists, call:sqlite3.connect, call:conn.execute(f"PRAGMA table_info({table})").fetchall, func:get_service_data_harness() → ServiceDataHarness, call:Path, call:os.environ.get, call:ServiceDataHarness, call:_HARNESS.register, call:_HARNESS.run_migrations, func:reset_service_data_harness() → None | dep: logging, os, sqlite3, dataclasses, pathlib, media_library_viewer_api.services.qbittorrent_store, media_library_viewer_api.services.media_index_impl, qbittorrent_store, media_index_impl - service_resolution.py | Provides a shared helper function to resolve a service instance by ID or fall back to the first enabled instance of a given service type. | exp: func:resolve_service_record(store: SettingsStore, service_type: str, service_id) → ServiceRecord | None, call:store.get_service, call:row.get, call:build_service_record, call:store.list_services | dep: media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources, media_library_viewer_api.services.settings_store.SettingsStore, media_library_viewer_api.widgets.sources.ServiceRecord, media_library_viewer_api.widgets.sources.build_service_record -- settings_store.py | Provides a SQLite-backed persistent settings store managing machine definitions, SSH keys, services, saved tasks, dashboard widgets, backup jobs, and related configuration for a media library viewer application. | exp: class:SettingsStore, method:__init__(self, db_path), call:Path, call:self.db_path.parent.mkdir, method:connect(self) → sqlite3.Connection, call:sqlite3.connect, call:conn.execute, method:init_schema(self) → None, call:self.connect, call:conn.execute("PRAGMA table_info(ssh_keys)").fetchall, call:conn.execute("PRAGMA table_info(saved_tasks)").fetchall, call:conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall, call:conn.execute("PRAGMA table_info(backup_jobs)").fetchall, method:_row_to_machine(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, call:self._normalize_services, call:data.get, call:bool, call:int, method:_normalize_machine_payload(self, payload: dict[str, Any], machine_id) → dict[str, Any], call:self.get_machine, call:str(payload.get("id") or machine_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("mode") or (current or {}).get("mode") or "local").strip().lower, call:(current or {}).get, call:bool, call:str(payload.get("name") or (current or {}).get("name") or "").strip, call:self._normalize_services, call:str( payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default ).strip, call:_current_str, call:int, method:_seed_local_machine(self) → None, call:_default_local_machine, call:int, call:time.time, call:machine.get, call:self.connect, call:conn.execute, call:json.dumps, method:_seed_dashboard_widgets(self) → None, method:ensure_defaults(self) → None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone, call:int, call:self._seed_local_machine, call:self._migrate_jellyseerr_into_jellyfin, method:_migrate_jellyseerr_into_jellyfin(self) → None, call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC" ).fetchall, call:self.list_services, call:json.loads, call:str(js_config.get("base_url", "")).strip, call:js_config.get, call:str(js_secrets.get("api_key", "")).strip, call:js_secrets.get, call:decrypt_value, call:logger.warning, call:len, call:str(jf["config"].get("jellyseerr_url", "")).strip, call:jf["config"].get, call:dict, call:self.upsert_service, call:target["secrets"].get, call:logger.info, call:conn.commit, method:list_machines(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM monitoring_machines ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, name COLLATE NOCASE", (LOCAL_MACHINE_ID,), ).fetchall, call:self._row_to_machine, method:get_machine(self, machine_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone, call:self._row_to_machine, method:get_machine_config(self, machine_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone, call:json.loads, call:bool, call:self._normalize_services, call:data.get, call:int, method:list_machines_for_service(self, service: str) → list[dict[str, Any]], call:self.list_machines, call:machine.get, method:get_machine_for_service(self, service: str, machine_id) → dict[str, Any] | None, call:self.get_machine, call:machine.get, call:self.list_machines_for_service, method:upsert_machine(self, payload: dict[str, Any], machine_id) → dict[str, Any], call:self.init_schema, call:self._normalize_machine_payload, call:int, call:time.time, call:machine.get, call:self.connect, call:conn.execute( "SELECT created_at FROM monitoring_machines WHERE id = ?", (machine["id"],), ).fetchone, call:json.dumps, call:self.get_machine, method:delete_machine(self, machine_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:_row_to_ssh_key(self, row: sqlite3.Row, usage_count) → dict[str, Any], call:self._private_key_summary, call:str, call:bool, method:_normalize_ssh_key_payload(self, payload: dict[str, Any], key_id) → dict[str, Any], call:self.get_ssh_key, call:str(payload.get("id") or key_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("name") or (current or {}).get("name") or key_id).strip, call:(current or {}).get, call:str( payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "" ).strip, call:self._private_key_summary, call:str( payload.get("public_key") if payload.get("public_key") is not None else (current or {}).get("public_key", "") or summary["public_key"] or "" ).strip, call:str( payload.get("fingerprint") if payload.get("fingerprint") is not None else (current or {}).get("fingerprint", "") or summary["fingerprint"] or "" ).strip, method:list_ssh_keys(self) → list[dict[str, Any]], call:self.init_schema, call:self.list_machines, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:usage_counts.get, call:self.connect, call:conn.execute("SELECT * FROM ssh_keys ORDER BY name COLLATE NOCASE").fetchall, call:self._row_to_ssh_key, method:get_ssh_key(self, key_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM ssh_keys WHERE id = ?", (key_id,)).fetchone, call:self._private_key_summary, call:str, method:upsert_ssh_key(self, payload: dict[str, Any], key_id) → dict[str, Any], call:self.init_schema, call:self._normalize_ssh_key_payload, call:int, call:time.time, call:self.connect, call:conn.execute("SELECT created_at FROM ssh_keys WHERE id = ?", (key["id"],)).fetchone, call:self.get_ssh_key, method:delete_ssh_key(self, key_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:_row_to_task(self, row: sqlite3.Row) → dict[str, Any], call:bool, method:_normalize_task_payload(self, payload: dict[str, Any], task_id) → dict[str, Any], call:self.get_task, call:str(payload.get("id") or task_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("name") or (current or {}).get("name") or task_id).strip, call:(current or {}).get, call:str(payload.get("task_type") or (current or {}).get("task_type") or "shell").strip().lower, call:bool, call:str( payload.get("default_service_id") if payload.get("default_service_id") is not None else (current or {}).get("default_service_id", "") or "" ).strip, call:str( payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "" ).strip, method:list_tasks(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM saved_tasks ORDER BY name COLLATE NOCASE").fetchall, call:self._row_to_task, method:get_task(self, task_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM saved_tasks WHERE id = ?", (task_id,)).fetchone, call:self._row_to_task, method:upsert_task(self, payload: dict[str, Any], task_id) → dict[str, Any], call:self.init_schema, call:self._normalize_task_payload, call:int, call:time.time, call:self.connect, call:conn.execute("SELECT created_at FROM saved_tasks WHERE id = ?", (task["id"],)).fetchone, call:self.get_task, method:delete_task(self, task_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:_row_to_shortcut(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, call:bool, call:target.get, method:_normalize_shortcut_payload(self, payload: dict[str, Any], shortcut_id) → dict[str, Any], call:self.get_shortcut, call:str(payload.get("id") or shortcut_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("shortcut_type") or (current or {}).get("shortcut_type") or "website").strip().lower, call:(current or {}).get, call:str(payload.get("label") or (current or {}).get("label") or "").strip, call:bool, call:str( payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default ).strip, call:_field, method:list_shortcuts(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM dashboard_shortcuts ORDER BY label COLLATE NOCASE").fetchall, call:self._row_to_shortcut, method:get_shortcut(self, shortcut_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM dashboard_shortcuts WHERE id = ?", (shortcut_id,)).fetchone, call:self._row_to_shortcut, method:upsert_shortcut(self, payload: dict[str, Any], shortcut_id) → dict[str, Any], call:self.init_schema, call:self._normalize_shortcut_payload, call:int, call:time.time, call:self.connect, call:conn.execute( "SELECT created_at FROM dashboard_shortcuts WHERE id = ?", (shortcut["id"],), ).fetchone, call:json.dumps, call:self.get_shortcut, method:delete_shortcut(self, shortcut_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:_row_to_job(self, row: sqlite3.Row) → dict[str, Any], method:_normalize_backup_job_payload(self, payload: dict[str, Any], job_id) → dict[str, Any], call:self.get_backup_job, call:str(payload.get("id") or job_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("name") or (current or {}).get("name") or job_id).strip, call:(current or {}).get, call:str( payload.get("source") if payload.get("source") is not None else (current or {}).get("source", "") or "" ).strip, call:str( payload.get("target") if payload.get("target") is not None else (current or {}).get("target", "") or "" ).strip, call:int, call:str( payload.get("service_id") if payload.get("service_id") is not None else (current or {}).get("service_id", "") or "" ).strip, method:get_backup_job_by_name(self, name: str) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_jobs WHERE name = ?", (name,)).fetchone, call:self._row_to_job, method:upsert_backup_job(self, payload: dict[str, Any]) → dict[str, Any], call:self.init_schema, call:self._normalize_backup_job_payload, call:int, call:time.time, call:self.connect, call:conn.execute("SELECT created_at FROM backup_jobs WHERE id = ?", (job["id"],)).fetchone, call:self.get_backup_job, method:get_backup_job(self, job_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone, call:self._row_to_job, method:list_backup_jobs(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_jobs ORDER BY created_at DESC").fetchall, call:self._row_to_job, method:_row_to_run(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, method:create_backup_run(self, payload: dict[str, Any]) → dict[str, Any], call:self.init_schema, call:str(payload.get("id") or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:int, call:time.time, call:json.dumps, call:self.connect, call:conn.execute, call:self.get_backup_run, method:get_backup_run(self, run_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_runs WHERE id = ?", (run_id,)).fetchone, call:self._row_to_run, method:list_backup_runs(self, job_id, status, limit) → list[dict[str, Any]], call:self.init_schema, call:clauses.append, call:params.append, call:' AND '.join, call:max, call:min, call:int, call:self.connect, call:conn.execute(sql, params).fetchall, call:self._row_to_run, method:get_latest_backup_run(self, job_id: str) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM backup_runs WHERE job_id = ? ORDER BY created_at DESC LIMIT 1", (job_id,), ).fetchone, call:self._row_to_run, method:_row_to_alert(self, row: sqlite3.Row) → dict[str, Any], call:bool, method:create_backup_alert(self, payload: dict[str, Any]) → dict[str, Any], call:self.init_schema, call:str(payload.get("id") or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:int, call:time.time, call:self.connect, call:conn.execute, call:self.get_backup_alert, method:get_backup_alert(self, alert_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_alerts WHERE id = ?", (alert_id,)).fetchone, call:self._row_to_alert, method:list_backup_alerts(self, job_id, acknowledged, severity) → list[dict[str, Any]], call:self.init_schema, call:clauses.append, call:params.append, call:' AND '.join, call:self.connect, call:conn.execute(sql, params).fetchall, call:self._row_to_alert, method:acknowledge_backup_alert(self, alert_id: str) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute, call:self.get_backup_alert, method:resolve_backup_alerts_for_job(self, job_id: str, alert_type: str) → int, call:self.init_schema, call:int, call:time.time, call:self.connect, call:conn.execute, method:prune_backup_alerts(self, cutoff_ts: int) → int, call:self.init_schema, call:self.connect, call:conn.execute, call:int, method:get_settings(self) → dict[str, Any], call:self.init_schema, call:self.connect, call:conn.execute("SELECT key, value FROM app_settings").fetchall, method:get_setting(self, key: str, default) → Any, call:self.init_schema, call:self.connect, call:conn.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone, method:update_setting(self, key: str, value: str) → None, call:self.init_schema, call:int, call:time.time, call:self.connect, call:conn.execute, method:_row_to_widget(self, row: sqlite3.Row) → dict[str, Any], call:row.keys, call:json.loads, call:bool, call:int, method:_normalize_widget_payload(self, payload: dict[str, Any], widget_id) → dict[str, Any], call:self.get_widget, call:str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("service_id") or (current or {}).get("service_id") or "").strip, call:(current or {}).get, call:str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip, call:str(payload.get("title") or (current or {}).get("title", "") or "").strip, call:isinstance, call:_validate_config_keys, call:bool, call:int, method:list_widgets(self, service_id, scope, all_widgets) → list[dict[str, Any]], call:self.init_schema, call:clauses.append, call:params.append, call:' AND '.join, call:self.connect, call:conn.execute( f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC", params, ).fetchall, call:self._row_to_widget, method:get_widget(self, widget_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)).fetchone, call:self._row_to_widget, method:upsert_widget(self, payload: dict[str, Any], widget_id) → dict[str, Any], call:self.init_schema, call:self._normalize_widget_payload, call:int, call:time.time, call:self.connect, call:conn.execute( "SELECT created_at FROM dashboard_widgets WHERE id = ?", (widget["id"],), ).fetchone, call:json.dumps, call:self.get_widget, method:delete_widget(self, widget_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:list_widget_references(self, dashboard_scope: str) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute( """ SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order, wr.created_at AS ref_created_at FROM widget_references wr WHERE wr.dashboard_scope = ? ORDER BY wr.sort_order ASC, wr.created_at ASC """, (dashboard_scope,), ).fetchall, call:self.get_widget, call:result.append, call:int, method:create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order) → dict[str, Any], call:self.init_schema, call:self.get_widget, call:uuid.uuid4, call:int, call:time.time, call:self.connect, call:conn.execute, raise:ValueError, method:delete_widget_reference(self, reference_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:update_widget_reference(self, reference_id: str, sort_order: int) → dict[str, Any], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM widget_references WHERE id = ?", (reference_id,), ).fetchone, call:self.get_widget, call:int, raise:ValueError, method:detach_widget_reference(self, reference_id: str, dashboard_scope: str) → dict[str, Any], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT widget_id FROM widget_references WHERE id = ?", (reference_id,), ).fetchone, call:self.get_widget, call:self.upsert_widget, call:source.get, call:self.delete_widget_reference, raise:ValueError, method:_row_to_service(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, call:bool, method:list_services(self, service_type) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM services WHERE service_type = ? ORDER BY name ASC", (service_type,), ).fetchall, call:conn.execute("SELECT * FROM services ORDER BY name ASC").fetchall, call:self._row_to_service, method:get_service(self, service_id: str) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM services WHERE id = ?", (service_id,)).fetchone, call:self._row_to_service, method:_normalize_service_payload(self, payload: dict[str, Any], service_id) → dict[str, Any], call:self.get_service, call:str(payload.get("id") or service_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("service_type") or (current or {}).get("service_type", "")).strip, call:(current or {}).get, call:str(payload.get("name") or (current or {}).get("name", "") or "").strip, call:isinstance, call:bool, method:upsert_service(self, payload: dict[str, Any], secret_values, service_id) → dict[str, Any], call:self.init_schema, call:self._normalize_service_payload, call:int, call:time.time, call:self.get_service, call:dict, call:secret_values.items, call:secrets_blob.pop, call:encrypt_value, call:self.connect, call:conn.execute, call:json.dumps, method:delete_service(self, service_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall, call:get_service_data_harness().cascade_delete, call:logger.exception, method:record_service_task_run(self, payload: dict[str, Any]) → dict[str, Any], call:self.init_schema, call:str, call:payload.get, call:uuid.uuid4, call:int, call:time.time, call:self.connect, call:conn.execute, method:list_service_task_runs(self, service_id, task_id, limit) → list[dict[str, Any]], call:self.init_schema, call:clauses.append, call:params.append, call:" AND ".join, call:int, call:self.connect, call:conn.execute( f"SELECT * FROM service_task_runs {where} ORDER BY created_at DESC LIMIT ?", params, ).fetchall, method:_unique_slug(self, slug: str, exclude_id) → str, call:self.init_schema, call:self.connect, call:conn.execute( "SELECT id FROM named_dashboards WHERE slug = ? AND id != ?", (slug, exclude_id or ""), ).fetchone, method:_row_to_dashboard(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, method:list_dashboards(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE" ).fetchall, call:self._row_to_dashboard, method:get_dashboard(self, dashboard_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone, call:self._row_to_dashboard, method:get_dashboard_by_slug(self, slug: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone, call:self._row_to_dashboard, method:upsert_dashboard(self, payload: dict[str, Any], dashboard_id) → dict[str, Any], call:self.init_schema, call:self.get_dashboard, call:str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip, call:(current or {}).get, call:str(payload.get("slug") or "").strip, call:self._slugify, call:self._unique_slug, call:int, call:time.time, call:self.connect, call:conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone, call:json.dumps, method:delete_dashboard(self, dashboard_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, func:_default_local_machine() → dict[str, Any], call:list, func:get_settings_store() → SettingsStore, call:SettingsStore | dep: json, logging, sqlite3, time, uuid, io, pathlib, typing, paramiko, media_library_viewer_api.models.widgets +- settings_store.py | SQLite-backed persistent storage manager for machine definitions, SSH keys, saved tasks, dashboard widgets, backup jobs, services, and task run history. | exp: class:SettingsStore, method:__init__(self, db_path), call:Path, call:self.db_path.parent.mkdir, method:connect(self) → sqlite3.Connection, call:sqlite3.connect, call:conn.execute, method:init_schema(self) → None, call:self.connect, call:conn.execute("PRAGMA table_info(ssh_keys)").fetchall, call:conn.execute("PRAGMA table_info(saved_tasks)").fetchall, call:conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall, call:conn.execute("PRAGMA table_info(backup_jobs)").fetchall, method:_row_to_machine(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, call:self._normalize_services, call:data.get, call:bool, call:int, method:_normalize_machine_payload(self, payload: dict[str, Any], machine_id) → dict[str, Any], call:self.get_machine, call:str(payload.get("id") or machine_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("mode") or (current or {}).get("mode") or "local").strip().lower, call:(current or {}).get, call:bool, call:str(payload.get("name") or (current or {}).get("name") or "").strip, call:self._normalize_services, call:str( payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default ).strip, call:_current_str, call:int, method:_seed_local_machine(self) → None, call:_default_local_machine, call:int, call:time.time, call:machine.get, call:self.connect, call:conn.execute, call:json.dumps, method:_seed_dashboard_widgets(self) → None, method:ensure_defaults(self) → None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone, call:int, call:self._seed_local_machine, call:self._migrate_jellyseerr_into_jellyfin, call:self._migrate_jellyseerr_api_key_to_secret, method:_migrate_jellyseerr_api_key_to_secret(self) → None, call:self.init_schema, call:self.list_services, call:dict, call:row.get, call:str(config.get("jellyseerr_api_key") or "").strip, call:config.get, call:encrypt_value, call:config.pop, call:self.connect, call:conn.execute, call:json.dumps, call:int, call:time.time, call:conn.commit, call:logger.info, method:_migrate_jellyseerr_into_jellyfin(self) → None, call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC" ).fetchall, call:self.list_services, call:json.loads, call:str(js_config.get("base_url", "")).strip, call:js_config.get, call:str(js_secrets.get("api_key", "")).strip, call:js_secrets.get, call:decrypt_value, call:logger.warning, call:len, call:str(jf["config"].get("jellyseerr_url", "")).strip, call:jf["config"].get, call:target["secrets"].get, call:dict, call:self.upsert_service, call:logger.info, call:conn.commit, method:list_machines(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM monitoring_machines ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, name COLLATE NOCASE", (LOCAL_MACHINE_ID,), ).fetchall, call:self._row_to_machine, method:get_machine(self, machine_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone, call:self._row_to_machine, method:get_machine_config(self, machine_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone, call:json.loads, call:bool, call:self._normalize_services, call:data.get, call:int, method:list_machines_for_service(self, service: str) → list[dict[str, Any]], call:self.list_machines, call:machine.get, method:get_machine_for_service(self, service: str, machine_id) → dict[str, Any] | None, call:self.get_machine, call:machine.get, call:self.list_machines_for_service, method:upsert_machine(self, payload: dict[str, Any], machine_id) → dict[str, Any], call:self.init_schema, call:self._normalize_machine_payload, call:int, call:time.time, call:machine.get, call:self.connect, call:conn.execute( "SELECT created_at FROM monitoring_machines WHERE id = ?", (machine["id"],), ).fetchone, call:json.dumps, call:self.get_machine, method:delete_machine(self, machine_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:_row_to_ssh_key(self, row: sqlite3.Row, usage_count) → dict[str, Any], call:self._private_key_summary, call:str, call:bool, method:_normalize_ssh_key_payload(self, payload: dict[str, Any], key_id) → dict[str, Any], call:self.get_ssh_key, call:str(payload.get("id") or key_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("name") or (current or {}).get("name") or key_id).strip, call:(current or {}).get, call:str( payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "" ).strip, call:self._private_key_summary, call:str( payload.get("public_key") if payload.get("public_key") is not None else (current or {}).get("public_key", "") or summary["public_key"] or "" ).strip, call:str( payload.get("fingerprint") if payload.get("fingerprint") is not None else (current or {}).get("fingerprint", "") or summary["fingerprint"] or "" ).strip, method:list_ssh_keys(self) → list[dict[str, Any]], call:self.init_schema, call:self.list_machines, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:usage_counts.get, call:self.connect, call:conn.execute("SELECT * FROM ssh_keys ORDER BY name COLLATE NOCASE").fetchall, call:self._row_to_ssh_key, method:get_ssh_key(self, key_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM ssh_keys WHERE id = ?", (key_id,)).fetchone, call:self._private_key_summary, call:str, method:upsert_ssh_key(self, payload: dict[str, Any], key_id) → dict[str, Any], call:self.init_schema, call:self._normalize_ssh_key_payload, call:int, call:time.time, call:self.connect, call:conn.execute("SELECT created_at FROM ssh_keys WHERE id = ?", (key["id"],)).fetchone, call:self.get_ssh_key, method:delete_ssh_key(self, key_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:_row_to_task(self, row: sqlite3.Row) → dict[str, Any], call:bool, method:_normalize_task_payload(self, payload: dict[str, Any], task_id) → dict[str, Any], call:self.get_task, call:str(payload.get("id") or task_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("name") or (current or {}).get("name") or task_id).strip, call:(current or {}).get, call:str(payload.get("task_type") or (current or {}).get("task_type") or "shell").strip().lower, call:bool, call:str( payload.get("default_service_id") if payload.get("default_service_id") is not None else (current or {}).get("default_service_id", "") or "" ).strip, call:str( payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "" ).strip, method:list_tasks(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM saved_tasks ORDER BY name COLLATE NOCASE").fetchall, call:self._row_to_task, method:get_task(self, task_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM saved_tasks WHERE id = ?", (task_id,)).fetchone, call:self._row_to_task, method:upsert_task(self, payload: dict[str, Any], task_id) → dict[str, Any], call:self.init_schema, call:self._normalize_task_payload, call:int, call:time.time, call:self.connect, call:conn.execute("SELECT created_at FROM saved_tasks WHERE id = ?", (task["id"],)).fetchone, call:self.get_task, method:delete_task(self, task_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:_row_to_shortcut(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, call:bool, call:target.get, method:_normalize_shortcut_payload(self, payload: dict[str, Any], shortcut_id) → dict[str, Any], call:self.get_shortcut, call:str(payload.get("id") or shortcut_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("shortcut_type") or (current or {}).get("shortcut_type") or "website").strip().lower, call:(current or {}).get, call:str(payload.get("label") or (current or {}).get("label") or "").strip, call:bool, call:str( payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default ).strip, call:_field, method:list_shortcuts(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM dashboard_shortcuts ORDER BY label COLLATE NOCASE").fetchall, call:self._row_to_shortcut, method:get_shortcut(self, shortcut_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM dashboard_shortcuts WHERE id = ?", (shortcut_id,)).fetchone, call:self._row_to_shortcut, method:upsert_shortcut(self, payload: dict[str, Any], shortcut_id) → dict[str, Any], call:self.init_schema, call:self._normalize_shortcut_payload, call:int, call:time.time, call:self.connect, call:conn.execute( "SELECT created_at FROM dashboard_shortcuts WHERE id = ?", (shortcut["id"],), ).fetchone, call:json.dumps, call:self.get_shortcut, method:delete_shortcut(self, shortcut_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:_row_to_job(self, row: sqlite3.Row) → dict[str, Any], method:_normalize_backup_job_payload(self, payload: dict[str, Any], job_id) → dict[str, Any], call:self.get_backup_job, call:str(payload.get("id") or job_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("name") or (current or {}).get("name") or job_id).strip, call:(current or {}).get, call:str( payload.get("source") if payload.get("source") is not None else (current or {}).get("source", "") or "" ).strip, call:str( payload.get("target") if payload.get("target") is not None else (current or {}).get("target", "") or "" ).strip, call:int, call:str( payload.get("service_id") if payload.get("service_id") is not None else (current or {}).get("service_id", "") or "" ).strip, method:get_backup_job_by_name(self, name: str) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_jobs WHERE name = ?", (name,)).fetchone, call:self._row_to_job, method:upsert_backup_job(self, payload: dict[str, Any]) → dict[str, Any], call:self.init_schema, call:self._normalize_backup_job_payload, call:int, call:time.time, call:self.connect, call:conn.execute("SELECT created_at FROM backup_jobs WHERE id = ?", (job["id"],)).fetchone, call:self.get_backup_job, method:get_backup_job(self, job_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone, call:self._row_to_job, method:list_backup_jobs(self, service_id) → list[dict[str, Any]], call:self.init_schema, call:params.append, call:self.connect, call:conn.execute(sql, params).fetchall, call:self._row_to_job, method:_row_to_run(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, method:create_backup_run(self, payload: dict[str, Any]) → dict[str, Any], call:self.init_schema, call:str(payload.get("id") or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:int, call:time.time, call:json.dumps, call:self.connect, call:conn.execute, call:self.get_backup_run, method:get_backup_run(self, run_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_runs WHERE id = ?", (run_id,)).fetchone, call:self._row_to_run, method:list_backup_runs(self, job_id, status, limit, service_id) → list[dict[str, Any]], call:self.init_schema, call:clauses.append, call:params.append, call:' AND '.join, call:max, call:min, call:int, call:self.connect, call:conn.execute(sql, params).fetchall, call:self._row_to_run, method:get_latest_backup_run(self, job_id: str) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM backup_runs WHERE job_id = ? ORDER BY created_at DESC LIMIT 1", (job_id,), ).fetchone, call:self._row_to_run, method:_row_to_alert(self, row: sqlite3.Row) → dict[str, Any], call:bool, method:create_backup_alert(self, payload: dict[str, Any]) → dict[str, Any], call:self.init_schema, call:str(payload.get("id") or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:int, call:time.time, call:self.connect, call:conn.execute, call:self.get_backup_alert, method:get_backup_alert(self, alert_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM backup_alerts WHERE id = ?", (alert_id,)).fetchone, call:self._row_to_alert, method:list_backup_alerts(self, job_id, acknowledged, severity, service_id) → list[dict[str, Any]], call:self.init_schema, call:clauses.append, call:params.append, call:' AND '.join, call:self.connect, call:conn.execute(sql, params).fetchall, call:self._row_to_alert, method:acknowledge_backup_alert(self, alert_id: str) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute, call:self.get_backup_alert, method:resolve_backup_alerts_for_job(self, job_id: str, alert_type: str) → int, call:self.init_schema, call:int, call:time.time, call:self.connect, call:conn.execute, method:prune_backup_alerts(self, cutoff_ts: int) → int, call:self.init_schema, call:self.connect, call:conn.execute, call:int, method:get_settings(self) → dict[str, Any], call:self.init_schema, call:self.connect, call:conn.execute("SELECT key, value FROM app_settings").fetchall, method:get_setting(self, key: str, default) → Any, call:self.init_schema, call:self.connect, call:conn.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone, method:update_setting(self, key: str, value: str) → None, call:self.init_schema, call:int, call:time.time, call:self.connect, call:conn.execute, method:_row_to_widget(self, row: sqlite3.Row) → dict[str, Any], call:row.keys, call:json.loads, call:bool, call:int, method:_normalize_widget_payload(self, payload: dict[str, Any], widget_id) → dict[str, Any], call:self.get_widget, call:str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("service_id") or (current or {}).get("service_id") or "").strip, call:(current or {}).get, call:str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip, call:str(payload.get("title") or (current or {}).get("title", "") or "").strip, call:isinstance, call:_validate_config_keys, call:bool, call:int, method:list_widgets(self, service_id, scope, all_widgets) → list[dict[str, Any]], call:self.init_schema, call:clauses.append, call:params.append, call:' AND '.join, call:self.connect, call:conn.execute( f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC", params, ).fetchall, call:self._row_to_widget, method:get_widget(self, widget_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)).fetchone, call:self._row_to_widget, method:upsert_widget(self, payload: dict[str, Any], widget_id) → dict[str, Any], call:self.init_schema, call:self._normalize_widget_payload, call:int, call:time.time, call:self.connect, call:conn.execute( "SELECT created_at FROM dashboard_widgets WHERE id = ?", (widget["id"],), ).fetchone, call:json.dumps, call:self.get_widget, method:delete_widget(self, widget_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:list_widget_references(self, dashboard_scope: str) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute( """ SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order, wr.created_at AS ref_created_at FROM widget_references wr WHERE wr.dashboard_scope = ? ORDER BY wr.sort_order ASC, wr.created_at ASC """, (dashboard_scope,), ).fetchall, call:self.get_widget, call:result.append, call:int, method:create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order) → dict[str, Any], call:self.init_schema, call:self.get_widget, call:uuid.uuid4, call:int, call:time.time, call:self.connect, call:conn.execute, raise:ValueError, method:delete_widget_reference(self, reference_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, method:update_widget_reference(self, reference_id: str, sort_order: int) → dict[str, Any], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM widget_references WHERE id = ?", (reference_id,), ).fetchone, call:self.get_widget, call:int, raise:ValueError, method:detach_widget_reference(self, reference_id: str, dashboard_scope: str) → dict[str, Any], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT widget_id FROM widget_references WHERE id = ?", (reference_id,), ).fetchone, call:self.get_widget, call:self.upsert_widget, call:source.get, call:self.delete_widget_reference, raise:ValueError, method:_row_to_service(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, call:bool, method:list_services(self, service_type) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM services WHERE service_type = ? ORDER BY name ASC", (service_type,), ).fetchall, call:conn.execute("SELECT * FROM services ORDER BY name ASC").fetchall, call:self._row_to_service, method:get_service(self, service_id: str) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM services WHERE id = ?", (service_id,)).fetchone, call:self._row_to_service, method:_normalize_service_payload(self, payload: dict[str, Any], service_id) → dict[str, Any], call:self.get_service, call:str(payload.get("id") or service_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("service_type") or (current or {}).get("service_type", "")).strip, call:(current or {}).get, call:str(payload.get("name") or (current or {}).get("name", "") or "").strip, call:isinstance, call:bool, method:upsert_service(self, payload: dict[str, Any], secret_values, service_id) → dict[str, Any], call:self.init_schema, call:self._normalize_service_payload, call:int, call:time.time, call:self.get_service, call:dict, call:secret_values.items, call:secrets_blob.pop, call:encrypt_value, call:self.connect, call:conn.execute, call:json.dumps, method:delete_service(self, service_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall, call:get_service_data_harness().cascade_delete, call:logger.exception, method:record_service_task_run(self, payload: dict[str, Any]) → dict[str, Any], call:self.init_schema, call:str, call:payload.get, call:uuid.uuid4, call:int, call:time.time, call:self.connect, call:conn.execute, method:list_service_task_runs(self, service_id, task_id, limit) → list[dict[str, Any]], call:self.init_schema, call:clauses.append, call:params.append, call:" AND ".join, call:int, call:self.connect, call:conn.execute( f"SELECT * FROM service_task_runs {where} ORDER BY created_at DESC LIMIT ?", params, ).fetchall, method:_unique_slug(self, slug: str, exclude_id) → str, call:self.init_schema, call:self.connect, call:conn.execute( "SELECT id FROM named_dashboards WHERE slug = ? AND id != ?", (slug, exclude_id or ""), ).fetchone, method:_row_to_dashboard(self, row: sqlite3.Row) → dict[str, Any], call:json.loads, method:list_dashboards(self) → list[dict[str, Any]], call:self.init_schema, call:self.connect, call:conn.execute( "SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE" ).fetchall, call:self._row_to_dashboard, method:get_dashboard(self, dashboard_id: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone, call:self._row_to_dashboard, method:get_dashboard_by_slug(self, slug: str | None) → dict[str, Any] | None, call:self.init_schema, call:self.connect, call:conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone, call:self._row_to_dashboard, method:upsert_dashboard(self, payload: dict[str, Any], dashboard_id) → dict[str, Any], call:self.init_schema, call:self.get_dashboard, call:str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip, call:payload.get, call:uuid.uuid4, call:str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip, call:(current or {}).get, call:str(payload.get("slug") or "").strip, call:self._slugify, call:self._unique_slug, call:int, call:time.time, call:self.connect, call:conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone, call:json.dumps, method:delete_dashboard(self, dashboard_id: str) → None, call:self.init_schema, call:self.connect, call:conn.execute, func:_default_local_machine() → dict[str, Any], call:list, func:get_settings_store() → SettingsStore, call:SettingsStore | dep: json, logging, sqlite3, time, uuid, io, pathlib, typing, paramiko, media_library_viewer_api.models.widgets - targets.py | Builds a Prometheus HTTP service discovery target list of remote Node Exporter endpoints from configured SSH machines. | exp: func:_scrape_address(machine: dict[str, Any]) → str | None, call:machine.get, call:str(machine.get("node_exporter_scrape_host") or "").strip, call:str(machine.get("host") or "").strip, call:int, func:build_node_exporter_targets(store: SettingsStore) → list[dict[str, Any]], call:store.list_machines, call:machine.get, call:str(machine.get("mode") or "local").strip().lower, call:_scrape_address, call:targets.append | dep: logging, typing, media_library_viewer_api.services.settings_store - task_runner.py | Provides a unified execution path for running saved tasks over SSH against ssh_tasks service instances, including client building, command rendering, execution, and run logging. | exp: class:TaskRunResult, func:build_ssh_client(store: SettingsStore, service: "ServiceRecord") → RemoteSSHClient, call:str(config.get("host") or "").strip, call:config.get, call:str(config.get("username") or "").strip, call:get_settings, call:str(config.get("ssh_key_id") or "").strip, call:store.get_ssh_key, call:ssh_key.get, call:service.secrets.get, call:RemoteSSHClient, call:int, raise:ValueError, func:_render_command(task: dict[str, Any]) → str, call:str(task.get("task_type") or "shell").lower, call:task.get, call:shlex.quote, raise:ValueError, func:run_saved_task(store: SettingsStore, task: dict[str, Any], service: "ServiceRecord", timeout) → TaskRunResult, call:int, call:service.config.get, call:build_ssh_client, call:_render_command, call:time.perf_counter, call:client.run, call:_record, call:str, call:logger.exception, call:task.get, call:TaskRunResult, func:_record(store: SettingsStore, task: dict[str, Any], service: "ServiceRecord", status: str, exit_status, duration_ms, stdout_tail, stderr_tail, error) → None, call:store.record_service_task_run, call:str, call:task.get | dep: logging, shlex, time, dataclasses, typing, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources, RemoteSSHClient, get_settings, SettingsStore, ServiceRecord ## arch -Module-per-concern service layer pattern with implementation-behind-facade re-exports, background daemon threads for async work, SQLite-backed persistence per service, and singleton harnesses for database lifecycle management. +Mix of singleton service harnesses, background daemon threads, SQLite-backed stores with facade re-export modules, and synchronous utility helpers. ## tags call:conn.execute, call:str, call:self.connect, schema, call:self., call:self.init, call:int, call: ## symbols diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index d5f0ef0..43c5166 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -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: diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index 9034bd1..816861f 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -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):