diff --git a/importarr/main.py b/importarr/main.py index 66c7c75..3124ae2 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Annotated import uvicorn +import httpx from fastapi import Depends, FastAPI, Header, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles @@ -50,6 +51,12 @@ class AppSettingsUpdate(BaseModel): sonarr_api_key: str | None = None +class ConnectionTestRequest(BaseModel): + service: str + url: str + 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) @@ -136,6 +143,33 @@ def update_ui_settings(payload: AppSettingsUpdate, _: None = Depends(require_wri return get_ui_settings() +@app.post("/api/settings/test-connection") +async def test_connection(payload: ConnectionTestRequest, _: None = Depends(require_write_auth)) -> dict[str, object]: + service = payload.service.strip().lower() + url = payload.url.strip().rstrip("/") + api_key = (payload.api_key or "").strip() or None + if service not in {"sabnzbd", "radarr", "sonarr"}: + raise HTTPException(status_code=400, detail="service must be sabnzbd, radarr, or sonarr") + if not url: + raise HTTPException(status_code=400, detail="URL is required") + if api_key is None: + api_key = getattr(settings, f"{service if service != 'sabnzbd' else 'sab'}_api_key") + try: + if service == "sabnzbd": + data = await SabnzbdClient(url, api_key).queue() + return {"ok": True, "service": service, "message": f"Connected to SABnzbd; {len(data.get('queue', {}).get('slots', []))} queued jobs visible."} + headers = {"X-Api-Key": api_key} if api_key else {} + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get(f"{url}/api/v3/system/status", headers=headers) + response.raise_for_status() + data = response.json() + name = str(data.get("appName") or service.title()) + version = str(data.get("version") or "unknown version") + return {"ok": True, "service": service, "message": f"Connected to {name} {version}."} + except Exception as exc: + return {"ok": False, "service": service, "message": f"Connection failed: {exc.__class__.__name__}"} + + 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 04fc320..9d6daaf 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}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} +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}output{display:block;margin-top:.5rem;color:#9ca3af}.success{color:#86efac}.error{color:#fca5a5} diff --git a/importarr/templates/index.html b/importarr/templates/index.html index e9bebba..53c0f13 100644 --- a/importarr/templates/index.html +++ b/importarr/templates/index.html @@ -74,16 +74,19 @@ SABnzbd +
Radarr +
Sonarr +

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

@@ -103,6 +106,7 @@ 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.querySelectorAll('[data-test-connection]').forEach(button=>button.addEventListener('click', async()=>{ const form=document.getElementById('settings-form'); const service=button.dataset.testConnection; const prefix=service==='sabnzbd'?'sab':service; const output=document.getElementById(`${service}-test-result`); output.textContent='Testing…'; output.className=''; const response=await postJson('/api/settings/test-connection',{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}); if(response.ok){ const data=await response.json(); output.textContent=data.message; output.className=data.ok?'success':'error'; } })); document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); }); refresh(); setInterval(refresh, 10000); diff --git a/tests/test_status.py b/tests/test_status.py index 9fe6549..4254ec8 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -46,6 +46,9 @@ def test_index_renders_settings_dialog(tmp_path, monkeypatch): assert "SABnzbd" in response.text assert "Radarr" in response.text assert "Sonarr" in response.text + assert "Test SABnzbd connection" in response.text + assert "Test Radarr connection" in response.text + assert "Test Sonarr connection" in response.text def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch): @@ -77,3 +80,37 @@ def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch) } assert main.settings.sab_api_key == "sab-secret" assert main.state.get_app_state("radarr_api_key") == "radarr-secret" + + +def test_sab_connection_test_reports_success(tmp_path, monkeypatch): + monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) + import importarr.main as main + + from fastapi.testclient import TestClient + + async def fake_queue(self): + return {"queue": {"slots": [{"name": "one"}, {"name": "two"}]}} + + monkeypatch.setattr(main.SabnzbdClient, "queue", fake_queue) + + response = TestClient(main.app).post( + "/api/settings/test-connection", + json={"service": "sabnzbd", "url": "http://sab:8080", "api_key": "secret"}, + ) + + assert response.status_code == 200 + assert response.json() == {"ok": True, "service": "sabnzbd", "message": "Connected to SABnzbd; 2 queued jobs visible."} + + +def test_connection_test_rejects_unknown_service(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/test-connection", + json={"service": "lidarr", "url": "http://lidarr:8686"}, + ) + + assert response.status_code == 400