Author SHA1 Message Date
daniels 8a3929fae4 Add release-aware self update #25 2026-07-29 20:36:22 +02:00
daniels b2c34ef995 Add light and dark UI palettes #30 2026-07-29 20:33:00 +02:00
daniels da4df50205 Tighten Importarr UI #30 2026-07-29 20:28:09 +02:00
daniels 2a7c39bf6a Make UI SABnzbd-style #30 2026-07-29 20:19:33 +02:00
daniels 5752e9fb2f Add API update controls #22 2026-07-29 20:11:26 +02:00
daniels 6cdf1f5d49 Improve desktop jobs queue layout #29 2026-07-29 20:06:03 +02:00
daniels 7c50def0c9 Display build dates in local time 2026-07-29 20:04:37 +02:00
daniels 2156989b4b Improve mobile jobs queue #29 2026-07-29 19:52:51 +02:00
daniels d15ea13cb3 Make UI mobile responsive #29 2026-07-29 18:58:05 +02:00
8 changed files with 356 additions and 27 deletions
+5
View File
@@ -43,6 +43,8 @@ sudo -n sh /opt/importarr/repo-upgrade.sh
The helper refuses to run when the checkout has uncommitted changes, then performs `git pull --ff-only`, reinstalls the package from the repo, restarts `importarr.service`, and prints service status. Use it after changes have been committed and pushed to `main`. The helper refuses to run when the checkout has uncommitted changes, then performs `git pull --ff-only`, reinstalls the package from the repo, restarts `importarr.service`, and prints service status. Use it after changes have been committed and pushed to `main`.
Installed deployments can expose the same operation through the authenticated API. `GET /api/control/update-check` queries the latest release from `IMPORTARR_UPDATE_RELEASE_URL` (default: this repository's Gitea latest-release API) and compares it with the running `IMPORTARR_VERSION`. `POST /api/control/update` performs the same check and only runs the update command when a newer release tag exists. Configure `IMPORTARR_UPDATE_COMMAND` when the default `sh deploy/repo-upgrade.sh` is not correct for the service working directory, and configure `IMPORTARR_RESTART_COMMAND` when the default `systemctl restart importarr.service` needs a wrapper such as sudo.
Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.0`, ...), pushed with tags, then installed from the tagged checkout or artifact. Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.0`, ...), pushed with tags, then installed from the tagged checkout or artifact.
### Required setup ### Required setup
@@ -75,6 +77,9 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
- `POST /api/control/pause` - `POST /api/control/pause`
- `POST /api/control/stop` - `POST /api/control/stop`
- `POST /api/control/cancel-current` - `POST /api/control/cancel-current`
- `POST /api/control/restart`
- `GET /api/control/update-check`
- `POST /api/control/update`
- `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }` - `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
- `POST /api/import/run-now` - `POST /api/import/run-now`
+19 -2
View File
@@ -8,14 +8,31 @@ from . import __version__
def build_info() -> dict[str, str]: def build_info() -> dict[str, str]:
build_date = os.getenv("IMPORTARR_BUILD_DATE", "development")
return { return {
"name": "Importarr", "name": "Importarr",
"version": os.getenv("IMPORTARR_VERSION", __version__), "version": os.getenv("IMPORTARR_VERSION", __version__),
"build_date": os.getenv("IMPORTARR_BUILD_DATE", "development"), "build_date": local_timestamp(build_date),
"git_sha": os.getenv("IMPORTARR_GIT_SHA", "development"), "git_sha": os.getenv("IMPORTARR_GIT_SHA", "development"),
"python": platform.python_version(), "python": platform.python_version(),
"started_at": STARTED_AT, "started_at": STARTED_AT,
} }
STARTED_AT = datetime.now(UTC).isoformat(timespec="seconds") def local_timestamp(value: str) -> str:
if value == "development":
return value
normalized = value.removesuffix("Z") + "+00:00" if value.endswith("Z") else value
try:
timestamp = datetime.fromisoformat(normalized)
except ValueError:
return value
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=UTC)
return timestamp.astimezone().isoformat(timespec="seconds")
STARTED_AT = datetime.now(UTC).astimezone().isoformat(timespec="seconds")
+18
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -21,6 +22,11 @@ class Settings(BaseModel):
sonarr_url: str | None = None sonarr_url: str | None = None
sonarr_api_key: str | None = None sonarr_api_key: str | None = None
auth_token: str | None = None auth_token: str | None = None
restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "importarr.service"])
update_command: list[str] = Field(default_factory=lambda: ["sh", "deploy/repo-upgrade.sh"])
update_release_url: str = "https://gitea.delphas.dk/api/v1/repos/daniels/importarr/releases/latest"
update_check_timeout_seconds: int = Field(default=15, ge=1)
control_command_timeout_seconds: int = Field(default=120, ge=1)
bind_host: str = "127.0.0.1" bind_host: str = "127.0.0.1"
bind_port: int = 8765 bind_port: int = 8765
poll_seconds: int = Field(default=60, ge=5) poll_seconds: int = Field(default=60, ge=5)
@@ -42,6 +48,11 @@ class Settings(BaseModel):
sonarr_url=os.getenv("IMPORTARR_SONARR_URL"), sonarr_url=os.getenv("IMPORTARR_SONARR_URL"),
sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"), sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"),
auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"), auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"),
restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "importarr.service"]),
update_command=_env_command("IMPORTARR_UPDATE_COMMAND", ["sh", "deploy/repo-upgrade.sh"]),
update_release_url=os.getenv("IMPORTARR_UPDATE_RELEASE_URL", "https://gitea.delphas.dk/api/v1/repos/daniels/importarr/releases/latest"),
update_check_timeout_seconds=int(os.getenv("IMPORTARR_UPDATE_CHECK_TIMEOUT_SECONDS", "15")),
control_command_timeout_seconds=int(os.getenv("IMPORTARR_CONTROL_COMMAND_TIMEOUT_SECONDS", "120")),
bind_host=os.getenv("IMPORTARR_BIND_HOST", "127.0.0.1"), bind_host=os.getenv("IMPORTARR_BIND_HOST", "127.0.0.1"),
bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")), bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")),
poll_seconds=int(os.getenv("IMPORTARR_POLL_SECONDS", "60")), poll_seconds=int(os.getenv("IMPORTARR_POLL_SECONDS", "60")),
@@ -63,3 +74,10 @@ def _env_secret(name: str) -> str | None:
if file_value: if file_value:
return Path(file_value).read_text(encoding="utf-8").strip() return Path(file_value).read_text(encoding="utf-8").strip()
return os.getenv(name) return os.getenv(name)
def _env_command(name: str, default: list[str]) -> list[str]:
value = os.getenv(name)
if not value:
return default
return shlex.split(value)
+103
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import subprocess
from typing import Annotated from typing import Annotated
import uvicorn import uvicorn
@@ -42,6 +43,22 @@ class QueueItemActionRequest(BaseModel):
action: str action: str
class ControlCommandResponse(BaseModel):
status: str
command: list[str]
returncode: int
stdout: str
stderr: str
class UpdateCheckResponse(BaseModel):
status: str
current_version: str
latest_version: str | None
update_available: bool
release_url: str | None = None
class AppSettingsUpdate(BaseModel): class AppSettingsUpdate(BaseModel):
sab_url: str sab_url: str
sab_api_key: str | None = None sab_api_key: str | None = None
@@ -236,6 +253,92 @@ def cancel_current(_: None = Depends(require_write_auth)) -> dict[str, object]:
return control_status() return control_status()
@app.post("/api/control/restart")
def restart_service(_: None = Depends(require_write_auth)) -> dict[str, object]:
return _run_control_command(settings.restart_command)
@app.post("/api/control/update")
def update_service(_: None = Depends(require_write_auth)) -> dict[str, object]:
update = check_update_available()
if not update["update_available"]:
return {**update, "command": settings.update_command, "stdout": "", "stderr": ""}
result = _run_control_command(settings.update_command)
return {**update, "command_result": result}
@app.get("/api/control/update-check")
def update_check(_: None = Depends(require_write_auth)) -> dict[str, object]:
return check_update_available()
def check_update_available() -> dict[str, object]:
current = build_info()["version"]
try:
with httpx.Client(timeout=settings.update_check_timeout_seconds) as client:
response = client.get(settings.update_release_url, headers={"Accept": "application/json"})
response.raise_for_status()
release = response.json()
except Exception as exc:
raise HTTPException(status_code=502, detail=f"release check failed: {exc.__class__.__name__}") from exc
latest = str(release.get("tag_name") or release.get("name") or "").strip()
if not latest:
raise HTTPException(status_code=502, detail="release check failed: latest release has no tag_name")
payload = UpdateCheckResponse(
status="update_available" if _is_newer_version(latest, current) else "current",
current_version=current,
latest_version=latest,
update_available=_is_newer_version(latest, current),
release_url=release.get("html_url"),
)
return payload.model_dump()
def _is_newer_version(candidate: str, current: str) -> bool:
candidate_version = _version_key(candidate)
current_version = _version_key(current)
if candidate_version is None or current_version is None:
return candidate.lstrip("vV") != current.lstrip("vV") and current in {"", "development"}
return candidate_version > current_version
def _version_key(value: str) -> tuple[int, ...] | None:
normalized = value.strip().lstrip("vV").split("-", 1)[0]
parts = normalized.split(".")
if not parts or any(not part.isdigit() for part in parts):
return None
return tuple(int(part) for part in parts)
def _run_control_command(command: list[str]) -> dict[str, object]:
if not command:
raise HTTPException(status_code=500, detail="control command is not configured")
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=settings.control_command_timeout_seconds,
)
except subprocess.TimeoutExpired as exc:
raise HTTPException(status_code=504, detail=f"control command timed out after {exc.timeout} seconds") from exc
except OSError as exc:
raise HTTPException(status_code=500, detail=f"control command failed to start: {exc.__class__.__name__}") from exc
payload = ControlCommandResponse(
status="ok" if result.returncode == 0 else "failed",
command=command,
returncode=result.returncode,
stdout=result.stdout[-4000:],
stderr=result.stderr[-4000:],
).model_dump()
if result.returncode != 0:
raise HTTPException(status_code=500, detail=payload)
return payload
@app.get("/api/manual-batches") @app.get("/api/manual-batches")
def manual_batches() -> list[dict[str, object]]: def manual_batches() -> list[dict[str, object]]:
if queue_accepting_new_jobs(): if queue_accepting_new_jobs():
File diff suppressed because one or more lines are too long
+42 -24
View File
@@ -8,18 +8,33 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div><h1>Importarr</h1><p>Manual SABnzbd imports, safely gated by SAB completion.</p></div> <div class="brand"><h1>Importarr</h1><p id="ready-state">{{ 'Running' if status.control.queue_mode == 'start' else status.control.queue_mode|capitalize }}</p></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> <div class="top-status"><span>Current</span><strong id="top-current-job">{{ status.current or 'idle' }}</strong></div>
<div class="top-controls">
<button type="button" data-control="start" aria-label="Start imports"></button>
<button type="button" data-control="pause" aria-label="Pause imports"></button>
<details class="menu">
<summary aria-label="Open menu"></summary>
<div class="menu-panel">
<button type="button" id="open-settings">Settings</button>
<button type="button" data-control="stop">Stop queue</button>
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
<button id="force-run" type="button">Force run now</button>
<a href="#manual-batches">Manual batches</a>
<a href="#service-info">Service info</a>
</div>
</details>
</div>
</header> </header>
<main> <main>
<section class="cards"> <section class="summary-strip" aria-label="Importarr summary">
<article><strong>{{ status.imported_total }}</strong><span>Imported total</span></article> <article><strong>{{ status.control.queue_mode }}</strong><span>Queue mode</span></article>
<article><strong>{{ status.failed_total }}</strong><span>Failed total</span></article> <article><strong>{{ status.current or 'Idle' }}</strong><span>Current import</span></article>
<article><strong>{{ status.manual_batches }}</strong><span>Manual batches</span></article> <article><strong>{{ status.imported_total }}</strong><span>Imported</span></article>
<article><strong>{{ status.category }}</strong><span>SAB category</span></article> <article><strong>{{ status.failed_total }}</strong><span>Failed</span></article>
</section> </section>
<section class="panel"> <details class="panel packed" id="service-info">
<h2>Service info</h2> <summary>Service info and build details</summary>
<dl class="info"> <dl class="info">
<dt>Name</dt><dd>{{ status.build.name }}</dd> <dt>Name</dt><dd>{{ status.build.name }}</dd>
<dt>Version</dt><dd>{{ status.build.version }}</dd> <dt>Version</dt><dd>{{ status.build.version }}</dd>
@@ -38,9 +53,9 @@
<dt>Queue mode</dt><dd id="queue-mode">{{ status.control.queue_mode }}</dd> <dt>Queue mode</dt><dd id="queue-mode">{{ status.control.queue_mode }}</dd>
<dt>Current job</dt><dd id="current-job">{{ status.current or 'idle' }}</dd> <dt>Current job</dt><dd id="current-job">{{ status.current or 'idle' }}</dd>
</dl> </dl>
</section> </details>
<section class="panel"> <details class="panel packed">
<h2>Queue controls</h2> <summary>Queue controls</summary>
<p>Pause and stop prevent new jobs from being added to the queue. They do not interrupt an import already in progress; use cancel current job for that.</p> <p>Pause and stop prevent new jobs from being added to the queue. They do not interrupt an import already in progress; use cancel current job for that.</p>
<div class="controls"> <div class="controls">
<button type="button" data-control="start">Start</button> <button type="button" data-control="start">Start</button>
@@ -48,22 +63,21 @@
<button type="button" data-control="stop">Stop</button> <button type="button" data-control="stop">Stop</button>
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button> <button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
</div> </div>
</section> </details>
<section> <details class="panel packed" id="manual-batches">
<h2>Manual batches</h2> <summary>Manual batches</summary>
<form id="batch-form" class="inline-form"> <form id="batch-form" class="inline-form">
<input name="path" placeholder="folder under download root"> <input name="path" placeholder="folder under download root">
<input id="batch-picker" type="file" webkitdirectory directory multiple hidden> <input id="batch-picker" type="file" webkitdirectory directory multiple hidden>
<button type="button" id="browse-batch">Browse…</button> <button type="button" id="browse-batch">Browse…</button>
<button>Add batch</button> <button>Add batch</button>
</form> </form>
<table><thead><tr><th>ID</th><th>Status</th><th>Path</th></tr></thead><tbody> <div class="table-scroll"><table><thead><tr><th>ID</th><th>Status</th><th>Path</th></tr></thead><tbody>
{% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %} {% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %}
</tbody></table> </tbody></table></div>
</section> </details>
<section class="panel"> <section class="panel queue-panel">
<div class="section-title"><h2>Jobs and queue</h2><button id="force-run" type="button">Force run now</button></div> <div class="section-title"><h2>Queue and history</h2><span>Grouped by processing state</span></div>
<p>Rows are grouped by processing state. Failed and skipped rows can be retried; ignore and remove actions only update Importarr's queue.</p>
<div id="jobs">Loading…</div> <div id="jobs">Loading…</div>
</section> </section>
</main> </main>
@@ -95,9 +109,11 @@
<script> <script>
const esc=value=>String(value??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); 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; } 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; }
function actionButtons(j){ const buttons=[]; if(j.can_run_now) buttons.push(`<button type="button" data-action="run-now" data-id="${j.id}">Run now</button>`); if(j.can_retry) buttons.push(`<button type="button" data-action="retry" data-id="${j.id}">Retry</button>`); if(j.can_ignore) buttons.push(`<button type="button" data-action="ignore" data-id="${j.id}" class="warn">Ignore</button>`); if(j.can_remove) buttons.push(`<button type="button" data-action="remove" data-id="${j.id}" class="danger">Remove</button>`); return buttons.join(' '); } function actionButtons(j){ const buttons=[]; if(j.can_run_now) buttons.push(`<button type="button" data-action="run-now" data-id="${j.id}" title="Run now">▶</button>`); if(j.can_retry) buttons.push(`<button type="button" data-action="retry" data-id="${j.id}" title="Retry">↻</button>`); if(j.can_ignore) buttons.push(`<button type="button" data-action="ignore" data-id="${j.id}" class="warn" title="Ignore">!</button>`); if(j.can_remove) buttons.push(`<button type="button" data-action="remove" data-id="${j.id}" class="danger" title="Remove">🗑</button>`); return buttons.join(' '); }
function renderJobs(data){ const groups=(data.groups||[]).filter(group=>group.jobs.length); if(!groups.length) return '<p>No queue items.</p>'; return groups.map(group=>`<section class="job-group"><h3>${esc(group.label)} <span>${group.jobs.length}</span></h3><table><thead><tr><th>File</th><th>Release / folder context</th><th>Readiness</th><th>SAB</th><th>Actions</th></tr></thead><tbody>${group.jobs.map(j=>`<tr><td><strong class="file-name">${esc(j.name)}</strong><small>${esc(j.source_type)}${j.batch_id?' · batch '+esc(j.batch_id):''}</small></td><td><small>${esc(j.relative_path||j.storage||j.source_id)}</small></td><td><span class="state">${esc(j.state)}</span><small>${esc(j.reason||'')}</small></td><td><small>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</small></td><td class="row-actions">${actionButtons(j)}</td></tr>`).join('')}</tbody></table></section>`).join(''); } function jobSubtext(j){ return `${esc(j.source_type)}${j.batch_id?' · batch '+esc(j.batch_id):''} · ${esc(j.relative_path||j.storage||j.source_id)}`; }
async function refresh(){ const r=await fetch('/api/jobs'); const d=await r.json(); document.title=d.jobs.length?`📥 ${d.jobs.length} jobs - Importarr`:'📥 - idle · Importarr'; document.getElementById('jobs').innerHTML=renderJobs(d); if(d.control){ document.getElementById('queue-mode').textContent=d.control.queue_mode; document.getElementById('current-job').textContent=d.control.current||'idle'; } } function readiness(j){ return `<span class="state" title="${esc(j.reason||j.state)}">${esc(j.state)}</span>`; }
function renderJobs(data){ const groups=(data.groups||[]).filter(group=>group.jobs.length); if(!groups.length) return '<p>No queue items.</p>'; return groups.map(group=>`<section class="job-group"><h3>${esc(group.label)} <span>${group.jobs.length}</span></h3><div class="table-scroll jobs-table"><table><thead><tr><th>File</th><th>Readiness</th><th>SAB</th><th>Actions</th></tr></thead><tbody>${group.jobs.map(j=>`<tr><td><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small></td><td>${readiness(j)}</td><td><small>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</small></td><td class="row-actions">${actionButtons(j)}</td></tr>`).join('')}</tbody></table></div><div class="job-cards">${group.jobs.map(j=>`<article class="job-card"><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small><dl><dt>Readiness</dt><dd>${readiness(j)}</dd><dt>SAB</dt><dd>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</dd></dl><div class="row-actions">${actionButtons(j)}</div></article>`).join('')}</div></section>`).join(''); }
async function refresh(){ const r=await fetch('/api/jobs'); const d=await r.json(); document.title=d.jobs.length?`📥 ${d.jobs.length} jobs - Importarr`:'📥 - idle · Importarr'; document.getElementById('jobs').innerHTML=renderJobs(d); if(d.control){ const current=d.control.current||'idle'; document.getElementById('queue-mode').textContent=d.control.queue_mode; document.getElementById('current-job').textContent=current; document.getElementById('top-current-job').textContent=current; document.getElementById('ready-state').textContent=d.control.queue_mode==='start'?'Running':d.control.queue_mode; } }
document.getElementById('jobs').addEventListener('click', async e=>{ const button=e.target.closest('button[data-action]'); if(!button)return; const action=button.dataset.action; const destructive=['ignore','remove'].includes(action); if(destructive&&!confirm(`${action} this Importarr queue item?`)) return; await postJson(`/api/queue-items/${button.dataset.id}/action`,{action}); await refresh(); }); document.getElementById('jobs').addEventListener('click', async e=>{ const button=e.target.closest('button[data-action]'); if(!button)return; const action=button.dataset.action; const destructive=['ignore','remove'].includes(action); if(destructive&&!confirm(`${action} this Importarr queue item?`)) return; await postJson(`/api/queue-items/${button.dataset.id}/action`,{action}); await refresh(); });
document.querySelectorAll('[data-control]').forEach(button=>button.addEventListener('click', async()=>{ if(button.dataset.control==='cancel-current'&&!confirm('Cancel the current import job?')) return; await postJson(`/api/control/${button.dataset.control}`); await refresh(); })); document.querySelectorAll('[data-control]').forEach(button=>button.addEventListener('click', async()=>{ if(button.dataset.control==='cancel-current'&&!confirm('Cancel the current import job?')) return; await postJson(`/api/control/${button.dataset.control}`); await refresh(); }));
document.getElementById('browse-batch').addEventListener('click',()=>document.getElementById('batch-picker').click()); document.getElementById('browse-batch').addEventListener('click',()=>document.getElementById('batch-picker').click());
@@ -105,6 +121,8 @@
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('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('open-settings').addEventListener('click',()=>document.getElementById('settings-dialog').showModal());
document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').close()); document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').close());
document.addEventListener('click',e=>{ document.querySelectorAll('details.menu[open]').forEach(menu=>{ if(!menu.contains(e.target)) menu.removeAttribute('open'); }); });
document.getElementById('settings-dialog').addEventListener('click',e=>{ if(e.target===e.currentTarget) e.currentTarget.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('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.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(); }); document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); });
+95
View File
@@ -131,3 +131,98 @@ def test_cancel_current_stops_active_copy(tmp_path, monkeypatch):
assert source.exists() assert source.exists()
assert not any(movies.glob("*.partial")) assert not any(movies.glob("*.partial"))
assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped" assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped"
def test_control_update_runs_configured_command(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
main.settings.update_command = ["upgrade", "now"]
monkeypatch.setattr(main, "check_update_available", lambda: {"status": "update_available", "current_version": "0.1.0", "latest_version": "0.2.0", "update_available": True, "release_url": None})
calls = []
def fake_run(command, **kwargs):
calls.append((command, kwargs))
return main.subprocess.CompletedProcess(command, 0, stdout="updated", stderr="")
monkeypatch.setattr(main.subprocess, "run", fake_run)
result = main.update_service()
assert result["status"] == "update_available"
assert result["command_result"]["status"] == "ok"
assert result["command_result"]["command"] == ["upgrade", "now"]
assert result["command_result"]["stdout"] == "updated"
assert calls[0][0] == ["upgrade", "now"]
assert calls[0][1].get("shell") is not True
def test_control_update_skips_command_when_current(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
main.settings.update_command = ["upgrade", "now"]
monkeypatch.setattr(main, "check_update_available", lambda: {"status": "current", "current_version": "0.2.0", "latest_version": "v0.2.0", "update_available": False, "release_url": None})
def fake_run(command, **kwargs):
raise AssertionError("update command should not run without a newer release")
monkeypatch.setattr(main.subprocess, "run", fake_run)
result = main.update_service()
assert result["status"] == "current"
assert result["command"] == ["upgrade", "now"]
assert result["update_available"] is False
def test_update_check_compares_latest_release(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
monkeypatch.setenv("IMPORTARR_VERSION", "0.1.0")
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"tag_name": "v0.2.0", "html_url": "https://example.test/releases/v0.2.0"}
class FakeClient:
def __init__(self, timeout):
self.timeout = timeout
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def get(self, url, headers):
assert url == main.settings.update_release_url
assert headers["Accept"] == "application/json"
return FakeResponse()
monkeypatch.setattr(main.httpx, "Client", FakeClient)
result = main.check_update_available()
assert result["status"] == "update_available"
assert result["current_version"] == "0.1.0"
assert result["latest_version"] == "v0.2.0"
assert result["update_available"] is True
def test_control_restart_reports_command_failure(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
main.settings.restart_command = ["restart"]
def fake_run(command, **kwargs):
return main.subprocess.CompletedProcess(command, 1, stdout="", stderr="failed")
monkeypatch.setattr(main.subprocess, "run", fake_run)
try:
main.restart_service()
except main.HTTPException as exc:
assert exc.status_code == 500
assert exc.detail["status"] == "failed"
assert exc.detail["stderr"] == "failed"
else:
raise AssertionError("expected HTTPException")
+70
View File
@@ -8,6 +8,19 @@ def test_health_contains_build_info(tmp_path, monkeypatch):
assert payload["version"] assert payload["version"]
def test_build_date_is_rendered_in_local_time(monkeypatch):
import importarr.build_info as build_info
monkeypatch.setenv("TZ", "Europe/Copenhagen")
import time
time.tzset()
monkeypatch.setenv("IMPORTARR_BUILD_DATE", "2026-07-29T12:00:00Z")
assert build_info.build_info()["build_date"] == "2026-07-29T14:00:00+02:00"
def test_status_contains_service_configuration(tmp_path, monkeypatch): def test_status_contains_service_configuration(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main import importarr.main as main
@@ -33,6 +46,25 @@ def test_index_renders_queue_controls(tmp_path, monkeypatch):
assert "cancel-current" in response.text assert "cancel-current" in response.text
def test_index_packs_secondary_controls_into_menu(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 'class="menu"' in response.text
assert "Service info and build details" in response.text
assert "Queue and history" in response.text
assert "Current import" in response.text
assert "details.menu[open]" in response.text
assert "e.target===e.currentTarget" in response.text
assert "function readiness" in response.text
assert 'title="${esc(j.reason||j.state)}"' in response.text
def test_index_renders_settings_dialog(tmp_path, monkeypatch): def test_index_renders_settings_dialog(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main import importarr.main as main
@@ -51,6 +83,44 @@ def test_index_renders_settings_dialog(tmp_path, monkeypatch):
assert "Test Sonarr connection" in response.text assert "Test Sonarr connection" in response.text
def test_index_renders_responsive_table_wrappers(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 'class="table-scroll"' in response.text
assert "job-cards" in response.text
assert "job-card" in response.text
assert '<meta name="viewport" content="width=device-width, initial-scale=1">' in response.text
def test_stylesheet_includes_mobile_responsive_rules():
from pathlib import Path
css = Path("importarr/static/importarr.css").read_text()
assert "@media (max-width:640px)" in css
assert ".table-scroll" in css
assert "overflow-x:auto" in css
assert "flex-direction:column" in css
assert ".jobs-table{display:none}" in css
assert ".job-cards{display:block}" in css
assert "word-break:break-word" in css
assert "max-width:1280px" in css
assert ".jobs-table table{table-layout:fixed;min-width:0}" in css
assert ".jobs-table th:nth-child(1){width:66%}" in css
assert ".row-actions button{width:1.85rem" in css
assert ".summary-strip" in css
assert ".menu-panel" in css
assert "@media (prefers-color-scheme:dark)" in css
assert "--primary:#4b42b8" in css
assert "--cyan:#50dce5" in css
def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch): def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main import importarr.main as main