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/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: