From 57c266dfa81c75e5e0f1860c6734f5ca61f2545c Mon Sep 17 00:00:00 2001 From: Daniel Gradman-Svendsen Date: Wed, 29 Jul 2026 14:57:38 +0200 Subject: [PATCH] Add settings dialog for Arr connections #21 --- importarr/main.py | 56 ++++++++++++++++++++++++++++++++++ importarr/static/importarr.css | 2 +- importarr/templates/index.html | 30 +++++++++++++++++- tests/test_status.py | 46 ++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/importarr/main.py b/importarr/main.py index 897a8d2..10e3ef2 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -41,6 +41,25 @@ class QueueItemActionRequest(BaseModel): action: str +class AppSettingsUpdate(BaseModel): + sab_url: str + sab_api_key: str | None = None + radarr_url: str | None = None + radarr_api_key: str | None = None + sonarr_url: str | None = None + sonarr_api_key: str | None = None + + +def load_ui_settings() -> None: + for key in ("sab_url", "sab_api_key", "radarr_url", "radarr_api_key", "sonarr_url", "sonarr_api_key"): + stored = state.get_app_state(key) + if stored is not None: + setattr(settings, key, stored or None) + + +load_ui_settings() + + def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None: if not settings.auth_token: return @@ -70,6 +89,11 @@ def status() -> dict[str, object]: "movies_root": str(settings.movies_root), "tv_root": str(settings.tv_root), "sab_url": settings.sab_url, + "sab_api_key_configured": bool(settings.sab_api_key), + "radarr_url": settings.radarr_url or "", + "radarr_api_key_configured": bool(settings.radarr_api_key), + "sonarr_url": settings.sonarr_url or "", + "sonarr_api_key_configured": bool(settings.sonarr_api_key), "auth_enabled": bool(settings.auth_token), "bind": f"{settings.bind_host}:{settings.bind_port}", "manual_batches": len(state.list_manual_batches(active_only=True)), @@ -80,6 +104,38 @@ def status() -> dict[str, object]: } +@app.get("/api/settings") +def get_ui_settings() -> dict[str, object]: + return { + "sab_url": settings.sab_url, + "sab_api_key_configured": bool(settings.sab_api_key), + "radarr_url": settings.radarr_url or "", + "radarr_api_key_configured": bool(settings.radarr_api_key), + "sonarr_url": settings.sonarr_url or "", + "sonarr_api_key_configured": bool(settings.sonarr_api_key), + } + + +@app.post("/api/settings") +def update_ui_settings(payload: AppSettingsUpdate, _: None = Depends(require_write_auth)) -> dict[str, object]: + sab_url = payload.sab_url.strip() + if not sab_url: + raise HTTPException(status_code=400, detail="SAB URL is required") + values = { + "sab_url": sab_url, + "sab_api_key": (payload.sab_api_key or "").strip(), + "radarr_url": (payload.radarr_url or "").strip(), + "radarr_api_key": (payload.radarr_api_key or "").strip(), + "sonarr_url": (payload.sonarr_url or "").strip(), + "sonarr_api_key": (payload.sonarr_api_key or "").strip(), + } + for key, value in values.items(): + state.set_app_state(key, value) + setattr(settings, key, value or None) + settings.sab_url = sab_url + return get_ui_settings() + + def control_status() -> dict[str, object]: mode = state.get_app_state("queue_mode", "running") or "running" current = state.get_app_state("current_job") diff --git a/importarr/static/importarr.css b/importarr/static/importarr.css index 2a4e44e..04fc320 100644 --- a/importarr/static/importarr.css +++ b/importarr/static/importarr.css @@ -1 +1 @@ -body{font-family:system-ui,sans-serif;margin:0;background:#111827;color:#e5e7eb}header,main{max-width:1100px;margin:auto;padding:1rem}.topbar{display:flex;justify-content:space-between;gap:1rem;align-items:center;background:#0f172a}.build{text-align:right}.build strong{font-size:1.2rem}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:1rem}.cards article,.panel{background:#1f2937;border-radius:.75rem;padding:1rem;margin-top:1rem}strong{display:block;font-size:2rem}span,small,dd{color:#9ca3af}table{width:100%;border-collapse:collapse;background:#1f2937;margin-top:1rem}th,td{padding:.6rem;border-bottom:1px solid #374151;text-align:left;vertical-align:top}input,button{padding:.6rem;border-radius:.4rem;border:1px solid #374151}button{background:#38bdf8;color:#082f49;font-weight:700;cursor:pointer}.danger{background:#f87171;color:#450a0a}.warn{background:#fbbf24;color:#451a03}.controls,.row-actions{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.4rem 1rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:#0f172a;border:1px solid #374151;border-radius:999px;padding:.15rem .5rem;display:inline-block}.section-title{display:flex;align-items:center;justify-content:space-between;gap:1rem}.job-group{margin-top:1.25rem}.job-group h3{display:flex;gap:.5rem;align-items:center}.job-group h3 span{font-size:.9rem;border:1px solid #374151;border-radius:999px;padding:.1rem .45rem}.file-name{font-size:1rem}.row-actions button{padding:.35rem .5rem}td small{display:block;overflow-wrap:anywhere} +body{font-family:system-ui,sans-serif;margin:0;background:#111827;color:#e5e7eb}header,main{max-width:1100px;margin:auto;padding:1rem}.topbar{display:flex;justify-content:space-between;gap:1rem;align-items:center;background:#0f172a}.build{text-align:right}.build strong{font-size:1.2rem}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:1rem}.cards article,.panel{background:#1f2937;border-radius:.75rem;padding:1rem;margin-top:1rem}strong{display:block;font-size:2rem}span,small,dd{color:#9ca3af}table{width:100%;border-collapse:collapse;background:#1f2937;margin-top:1rem}th,td{padding:.6rem;border-bottom:1px solid #374151;text-align:left;vertical-align:top}input,button{padding:.6rem;border-radius:.4rem;border:1px solid #374151}button{background:#38bdf8;color:#082f49;font-weight:700;cursor:pointer}.danger{background:#f87171;color:#450a0a}.warn{background:#fbbf24;color:#451a03}.controls,.row-actions{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.4rem 1rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:#0f172a;border:1px solid #374151;border-radius:999px;padding:.15rem .5rem;display:inline-block}.section-title{display:flex;align-items:center;justify-content:space-between;gap:1rem}.job-group{margin-top:1.25rem}.job-group h3{display:flex;gap:.5rem;align-items:center}.job-group h3 span{font-size:.9rem;border:1px solid #374151;border-radius:999px;padding:.1rem .45rem}.file-name{font-size:1rem}.row-actions button{padding:.35rem .5rem}td small{display:block;overflow-wrap:anywhere}dialog{background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:.75rem;max-width:min(42rem,90vw)}dialog::backdrop{background:#0009}fieldset{border:1px solid #374151;border-radius:.5rem;margin:1rem 0;padding:1rem}label{display:grid;gap:.35rem;margin:.75rem 0}.hint{color:#9ca3af} diff --git a/importarr/templates/index.html b/importarr/templates/index.html index 4076bd6..e9bebba 100644 --- a/importarr/templates/index.html +++ b/importarr/templates/index.html @@ -9,7 +9,7 @@

Importarr

Manual SABnzbd imports, safely gated by SAB completion.

-
{{ status.build.version }}{{ status.build.git_sha[:12] }} · {{ status.build.build_date }}
+
{{ status.build.version }}{{ status.build.git_sha[:12] }} · {{ status.build.build_date }}
@@ -28,6 +28,9 @@
Started
{{ status.build.started_at }}
Python
{{ status.build.python }}
SAB URL
{{ status.sab_url }}
+
SAB API token
{{ 'configured' if status.sab_api_key_configured else 'not configured' }}
+
Radarr
{{ status.radarr_url or 'not configured' }}
+
Sonarr
{{ status.sonarr_url or 'not configured' }}
Download root
{{ status.download_root }}
Movies root
{{ status.movies_root }}
TV root
{{ status.tv_root }}
@@ -64,6 +67,28 @@
Loading…
+ +
+

Settings

+
+ SABnzbd + + +
+
+ Radarr + + +
+
+ Sonarr + + +
+

Blank token fields clear the stored token. Environment values remain the startup defaults until saved here.

+ +
+
diff --git a/tests/test_status.py b/tests/test_status.py index d043441..9fe6549 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -31,3 +31,49 @@ def test_index_renders_queue_controls(tmp_path, monkeypatch): assert response.status_code == 200 assert "Queue controls" in response.text assert "cancel-current" in response.text + + +def test_index_renders_settings_dialog(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + + from fastapi.testclient import TestClient + + response = TestClient(main.app).get("/") + + assert response.status_code == 200 + assert "settings-dialog" in response.text + assert "SABnzbd" in response.text + assert "Radarr" in response.text + assert "Sonarr" in response.text + + +def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + + from fastapi.testclient import TestClient + + response = TestClient(main.app).post( + "/api/settings", + json={ + "sab_url": "http://sab:8080", + "sab_api_key": "sab-secret", + "radarr_url": "http://radarr:7878", + "radarr_api_key": "radarr-secret", + "sonarr_url": "http://sonarr:8989", + "sonarr_api_key": "sonarr-secret", + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "sab_url": "http://sab:8080", + "sab_api_key_configured": True, + "radarr_url": "http://radarr:7878", + "radarr_api_key_configured": True, + "sonarr_url": "http://sonarr:8989", + "sonarr_api_key_configured": True, + } + assert main.settings.sab_api_key == "sab-secret" + assert main.state.get_app_state("radarr_api_key") == "radarr-secret"