Add settings dialog for Arr connections #21

This commit is contained in:
2026-07-29 14:57:38 +02:00
parent a443909d64
commit 57c266dfa8
4 changed files with 132 additions and 2 deletions
+56
View File
@@ -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")
+1 -1
View File
@@ -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}
+29 -1
View File
@@ -9,7 +9,7 @@
<body>
<header class="topbar">
<div><h1>Importarr</h1><p>Manual SABnzbd imports, safely gated by SAB completion.</p></div>
<div class="build"><strong>{{ status.build.version }}</strong><span>{{ status.build.git_sha[:12] }} · {{ status.build.build_date }}</span></div>
<div class="build"><strong>{{ status.build.version }}</strong><span>{{ status.build.git_sha[:12] }} · {{ status.build.build_date }}</span><button type="button" id="open-settings">Settings</button></div>
</header>
<main>
<section class="cards">
@@ -28,6 +28,9 @@
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
<dt>Python</dt><dd>{{ status.build.python }}</dd>
<dt>SAB URL</dt><dd>{{ status.sab_url }}</dd>
<dt>SAB API token</dt><dd id="sab-token-status">{{ 'configured' if status.sab_api_key_configured else 'not configured' }}</dd>
<dt>Radarr</dt><dd>{{ status.radarr_url or 'not configured' }}</dd>
<dt>Sonarr</dt><dd>{{ status.sonarr_url or 'not configured' }}</dd>
<dt>Download root</dt><dd>{{ status.download_root }}</dd>
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
<dt>TV root</dt><dd>{{ status.tv_root }}</dd>
@@ -64,6 +67,28 @@
<div id="jobs">Loading…</div>
</section>
</main>
<dialog id="settings-dialog">
<form id="settings-form" method="dialog">
<div class="section-title"><h2>Settings</h2><button type="button" id="close-settings">Close</button></div>
<fieldset>
<legend>SABnzbd</legend>
<label>SAB URL <input name="sab_url" type="url" value="{{ status.sab_url }}" placeholder="http://sabnzbd:8080" required></label>
<label>API token <input name="sab_api_key" type="password" placeholder="{% if status.sab_api_key_configured %}Configured; enter a new token to replace{% else %}SAB API token{% endif %}" autocomplete="off"></label>
</fieldset>
<fieldset>
<legend>Radarr</legend>
<label>Radarr URL <input name="radarr_url" type="url" value="{{ status.radarr_url }}" placeholder="http://radarr:7878"></label>
<label>API token <input name="radarr_api_key" type="password" placeholder="{% if status.radarr_api_key_configured %}Configured; enter a new token to replace{% else %}Radarr API token{% endif %}" autocomplete="off"></label>
</fieldset>
<fieldset>
<legend>Sonarr</legend>
<label>Sonarr URL <input name="sonarr_url" type="url" value="{{ status.sonarr_url }}" placeholder="http://sonarr:8989"></label>
<label>API token <input name="sonarr_api_key" type="password" placeholder="{% if status.sonarr_api_key_configured %}Configured; enter a new token to replace{% else %}Sonarr API token{% endif %}" autocomplete="off"></label>
</fieldset>
<p class="hint">Blank token fields clear the stored token. Environment values remain the startup defaults until saved here.</p>
<button type="submit">Save settings</button>
</form>
</dialog>
<script>
const esc=value=>String(value??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
async function postJson(url, body){ const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:body?JSON.stringify(body):undefined}); if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); alert(error.detail||'Request failed'); } return response; }
@@ -75,6 +100,9 @@
document.getElementById('browse-batch').addEventListener('click',()=>document.getElementById('batch-picker').click());
document.getElementById('batch-picker').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f)return; const top=(f.webkitRelativePath||'').split('/')[0]; if(top) document.querySelector('#batch-form [name="path"]').value=top; });
document.getElementById('batch-form').addEventListener('submit', async e=>{ e.preventDefault(); const response=await postJson('/api/manual-batches',{path:e.target.path.value}); if(response.ok) location.reload(); });
document.getElementById('open-settings').addEventListener('click',()=>document.getElementById('settings-dialog').showModal());
document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').close());
document.getElementById('settings-form').addEventListener('submit', async e=>{ e.preventDefault(); const body=Object.fromEntries(new FormData(e.target)); const response=await postJson('/api/settings',body); if(response.ok){ const data=await response.json(); document.getElementById('sab-token-status').textContent=data.sab_api_key_configured?'configured':'not configured'; ['sab_api_key','radarr_api_key','sonarr_api_key'].forEach(name=>e.target.elements[name].value=''); document.getElementById('settings-dialog').close(); } });
document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); });
refresh(); setInterval(refresh, 10000);
</script>
+46
View File
@@ -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"