From 006db01930530b81305333362aa3007e45b1b0d0 Mon Sep 17 00:00:00 2001 From: Daniel Gradman-Svendsen Date: Wed, 29 Jul 2026 21:02:18 +0200 Subject: [PATCH] Fix import queue UI controls #31 --- importarr/importer.py | 11 +++++- importarr/main.py | 43 +++++++++++++++++---- importarr/static/importarr.css | 1 + importarr/templates/index.html | 69 ++++++++++++++++++++++------------ tests/test_queue_controls.py | 14 +++++++ tests/test_status.py | 17 +++++++++ 6 files changed, 120 insertions(+), 35 deletions(-) diff --git a/importarr/importer.py b/importarr/importer.py index 2730941..d7f1126 100644 --- a/importarr/importer.py +++ b/importarr/importer.py @@ -27,10 +27,14 @@ class Importer: target_root = self.tv_root if _looks_like_tv(source) else self.movies_root return _unique_path(target_root / source.name) - def import_file(self, source: Path, should_cancel: Callable[[], bool] | None = None) -> ImportResult: + def import_file(self, source: Path, should_cancel: Callable[[], bool] | None = None, on_progress: Callable[[int, int], None] | None = None) -> ImportResult: target = self.target_for(source) target.parent.mkdir(parents=True, exist_ok=True) partial = target.with_name(target.name + ".partial") + total = source.stat().st_size + copied = 0 + if on_progress: + on_progress(copied, total) try: with source.open("rb") as src, partial.open("wb") as dst: while True: @@ -40,12 +44,15 @@ class Importer: if not chunk: break dst.write(chunk) + copied += len(chunk) + if on_progress: + on_progress(copied, total) dst.flush() os.fsync(dst.fileno()) except ImportCancelled: partial.unlink(missing_ok=True) raise - if partial.stat().st_size != source.stat().st_size: + if partial.stat().st_size != total: raise IOError("partial copy size mismatch") partial.rename(target) source.unlink() diff --git a/importarr/main.py b/importarr/main.py index 02880da..0ed681b 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -1,7 +1,9 @@ from __future__ import annotations +import json from pathlib import Path import subprocess +import time from typing import Annotated import uvicorn @@ -189,7 +191,7 @@ async def test_connection(payload: ConnectionTestRequest, _: None = Depends(requ def control_status() -> dict[str, object]: mode = state.get_app_state("queue_mode", "running") or "running" - current = state.get_app_state("current_job") + current = current_job_status() cancel_requested = (state.get_app_state("cancel_requested", "false") or "false") == "true" return { "queue_mode": mode, @@ -199,6 +201,21 @@ def control_status() -> dict[str, object]: } +def current_job_status() -> dict[str, object] | str: + raw = state.get_app_state("current_job") or "" + if not raw: + return "" + try: + data = json.loads(raw) + except json.JSONDecodeError: + return raw + if isinstance(data, dict): + started_at = float(data.get("started_at") or time.time()) + data["elapsed_seconds"] = max(0, int(time.time() - started_at)) + return data + return raw + + def queue_accepting_new_jobs() -> bool: return (state.get_app_state("queue_mode", "running") or "running") == "running" @@ -214,8 +231,17 @@ def consume_cancel_request() -> bool: return True -def set_current_job(name: str | None) -> None: - state.set_app_state("current_job", name or "") +def set_current_job(name: str | None, *, bytes_copied: int = 0, total_bytes: int = 0, started_at: float | None = None) -> float: + started = started_at or time.time() + if not name: + state.set_app_state("current_job", "") + return started + percent = round((bytes_copied / total_bytes * 100), 2) if total_bytes else 0 + state.set_app_state( + "current_job", + json.dumps({"file": name, "bytes_copied": bytes_copied, "total_bytes": total_bytes, "percent": percent, "started_at": started}), + ) + return started @app.post("/api/control/queue") @@ -537,7 +563,8 @@ async def _import_ready_sab_jobs(importer: Importer, force: bool = False) -> int return imported set_current_job(str(video.path)) try: - result = importer.import_file(video.path, should_cancel=consume_cancel_request) + started = time.time() + result = importer.import_file(video.path, should_cancel=consume_cancel_request, on_progress=lambda copied, total, path=str(video.path), started=started: set_current_job(path, bytes_copied=copied, total_bytes=total, started_at=started)) state.add_history(result.source, result.target, "imported", result.bytes) state.mark_queue_item("sab", str(video.path), "imported") imported += 1 @@ -561,9 +588,9 @@ def _import_queue_item(item: dict[str, object], importer: Importer) -> int: state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "failed", "missing source path") return 0 source = Path(str(source_path)) - set_current_job(str(source)) + started = set_current_job(str(source)) try: - result = importer.import_file(source, should_cancel=consume_cancel_request) + result = importer.import_file(source, should_cancel=consume_cancel_request, on_progress=lambda copied, total: set_current_job(str(source), bytes_copied=copied, total_bytes=total, started_at=started)) state.add_history(result.source, result.target, "imported", result.bytes) state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "imported") return 1 @@ -590,9 +617,9 @@ def _import_manual_batches(importer: Importer) -> int: if consume_cancel_request(): return imported source = Path(item["source_path"]) - set_current_job(str(source)) + started = set_current_job(str(source)) try: - result = importer.import_file(source, should_cancel=consume_cancel_request) + result = importer.import_file(source, should_cancel=consume_cancel_request, on_progress=lambda copied, total, source=source, started=started: set_current_job(str(source), bytes_copied=copied, total_bytes=total, started_at=started)) state.add_history(result.source, result.target, "imported", result.bytes) state.mark_queue_item("manual", item["source_id"], "imported") imported += 1 diff --git a/importarr/static/importarr.css b/importarr/static/importarr.css index f2d8e15..61209ba 100644 --- a/importarr/static/importarr.css +++ b/importarr/static/importarr.css @@ -1,3 +1,4 @@ :root{color-scheme:light;--bg:#fff;--surface:#fff;--surface-2:#f2f7ff;--line:#d9e6fb;--text:#2f3b52;--muted:#8da5c3;--heading:#3f3db6;--primary:#4b42b8;--primary-2:#776df2;--accent:#7db3f1;--cyan:#50dce5;--purple:#a56af0;--danger:#a56af0;--warn:#50dce5;--shadow:#4b42b814}body{font-family:system-ui,sans-serif;margin:0;background:var(--bg);color:var(--text);font-size:14px}header,main{max-width:1280px;margin:auto;box-sizing:border-box}.topbar{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.8rem;align-items:center;background:var(--surface);border-bottom:1px solid var(--line);padding:.45rem .9rem;position:sticky;top:0;z-index:2;box-shadow:0 .45rem 1.2rem var(--shadow)}.brand h1{font-size:1.35rem;margin:0;color:var(--primary)}.brand p,.top-status span{margin:0;color:var(--muted)}.top-status{min-width:0;text-align:center}.top-status strong{font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.top-controls{display:flex;align-items:center;gap:.3rem}.top-controls button,.menu summary{min-width:2.25rem;min-height:2.15rem;padding:.35rem;border-radius:.3rem;background:var(--primary);color:#fff;border:1px solid var(--primary-2);text-align:center}.menu{position:relative}.menu summary{list-style:none;cursor:pointer;font-weight:800;font-size:1.15rem}.menu summary::-webkit-details-marker{display:none}.menu-panel{position:absolute;right:0;top:calc(100% + .35rem);display:grid;gap:.35rem;min-width:13rem;background:var(--surface);border:1px solid var(--line);padding:.5rem;box-shadow:0 .7rem 2rem var(--shadow)}.menu-panel a,.menu-panel button{display:block;width:100%;box-sizing:border-box;text-align:left;color:var(--text);background:var(--surface-2);text-decoration:none;border:1px solid var(--line);padding:.5rem;border-radius:.25rem;font-weight:700}.menu-panel .danger{background:var(--purple);color:#fff}main{padding:0 .9rem .9rem}.summary-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--line);margin-top:.8rem;border:1px solid var(--line);box-shadow:0 .6rem 1.5rem var(--shadow)}.summary-strip article,.panel{background:var(--surface)}.summary-strip article{padding:.55rem;min-width:0}.summary-strip strong{display:block;font-size:.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.summary-strip span,small,dd{color:var(--muted)}.panel{padding:.8rem;margin-top:.8rem;border:1px solid var(--line);box-shadow:0 .5rem 1.3rem var(--shadow)}.packed{background:var(--surface-2)}.packed summary{cursor:pointer;font-weight:800;font-size:1rem;color:var(--primary)}.queue-panel{background:var(--surface);color:var(--text)}.section-title{display:flex;justify-content:space-between;gap:1rem;align-items:center}.section-title h2,.job-group h3{font-size:1.55rem;line-height:1;margin:.2rem 0 .65rem;color:var(--heading)}.section-title span,.job-group h3 span{font-size:.85rem;color:var(--muted)}table{width:100%;border-collapse:collapse;background:var(--surface);margin-top:.35rem}.table-scroll{max-width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-scroll table{min-width:42rem}.jobs-table table{table-layout:fixed;min-width:0}.jobs-table th:nth-child(1){width:66%}.jobs-table th:nth-child(2){width:12%}.jobs-table th:nth-child(3){width:10%}.jobs-table th:nth-child(4){width:12%}.jobs-table td{overflow:hidden;text-overflow:ellipsis}.jobs-table .file-name{font-size:.9rem;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.jobs-table small{display:block;font-size:.76rem;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}th,td{padding:.36rem .45rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:middle}th{font-size:.75rem;color:var(--accent);font-weight:700}input,button{padding:.48rem;border-radius:.25rem;border:1px solid var(--line);box-sizing:border-box}button{background:var(--primary);color:#fff;font-weight:700;cursor:pointer;min-height:2.15rem}.danger{background:var(--danger);color:#fff}.warn{background:var(--warn);color:#1e1b4b}.controls,.row-actions{display:flex;gap:.25rem;flex-wrap:nowrap}.row-actions button{width:1.85rem;min-width:1.85rem;min-height:1.85rem;padding:.2rem;font-size:.82rem;line-height:1}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.6rem}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.35rem .8rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:var(--primary);border:1px solid var(--primary-2);border-radius:999px;padding:.08rem .42rem;display:inline-block;color:#fff;font-size:.75rem;font-weight:800;line-height:1.4}.job-group{margin-top:.8rem}.job-group h3{display:flex;justify-content:space-between}.job-cards{display:none}.job-card{background:var(--surface-2);border:1px solid var(--line);margin:.4rem 0;padding:.58rem}.job-card .file-name{font-size:.95rem;word-break:break-word}.job-card dl{display:grid;grid-template-columns:5rem 1fr;gap:.25rem;margin:.45rem 0}.job-card dt{font-weight:700}.job-card dd{margin:0;word-break:break-word}dialog{max-width:min(48rem,95vw);background:var(--surface);color:var(--text);border:1px solid var(--line);border-radius:.6rem;box-shadow:0 1rem 3rem var(--shadow)}dialog::backdrop{background:#0f172a99}fieldset{border:1px solid var(--line);margin:.8rem 0}label{display:grid;gap:.3rem;margin:.6rem 0}.success{color:var(--cyan)}.error{color:var(--purple)}.hint{color:var(--muted)} @media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#2f3d55;--surface:#40536f;--surface-2:#374963;--line:#506684;--text:#f8fbff;--muted:#bed1e9;--heading:#fff;--primary:#776df2;--primary-2:#4b42b8;--accent:#7db3f1;--cyan:#50dce5;--purple:#a56af0;--danger:#a56af0;--warn:#50dce5;--shadow:#18223666}.topbar{background:#2f3d55}.packed{background:#374963}.queue-panel,.summary-strip article,.panel,table,dialog{background:#40536f}.menu-panel .danger{background:var(--purple);color:#fff}} @media (max-width:640px){header,main{max-width:1280px}.topbar{grid-template-columns:auto 1fr auto;padding:.4rem .55rem}.brand h1{font-size:1.05rem}.brand p{font-size:.75rem}.top-status{text-align:left}.top-status span{display:none}.top-status strong{font-size:.85rem}.summary-strip{grid-template-columns:1fr 1fr}.summary-strip article{padding:.5rem}.panel{padding:.65rem}.section-title h2,.job-group h3{font-size:1.35rem}.jobs-table{display:none}.job-cards{display:block}.table-scroll{overflow-x:auto}.controls,.inline-form{flex-direction:column}.controls button,.inline-form button,.inline-form input{width:100%}.info{grid-template-columns:1fr}.menu-panel{right:0;min-width:12rem}.job-card .file-name{word-break:break-word}.row-actions{justify-content:flex-end}.row-actions button{flex:0 0 1.9rem}} +html[data-theme="light"]{color-scheme:light;--bg:#fff;--surface:#fff;--surface-2:#f2f7ff;--line:#d9e6fb;--text:#2f3b52;--muted:#8da5c3;--heading:#3f3db6;--primary:#4b42b8;--primary-2:#776df2;--accent:#7db3f1;--cyan:#50dce5;--purple:#a56af0;--danger:#a56af0;--warn:#50dce5;--shadow:#4b42b814}html[data-theme="dark"]{color-scheme:dark;--bg:#2f3d55;--surface:#40536f;--surface-2:#374963;--line:#506684;--text:#f8fbff;--muted:#bed1e9;--heading:#fff;--primary:#776df2;--primary-2:#4b42b8;--accent:#7db3f1;--cyan:#50dce5;--purple:#a56af0;--danger:#a56af0;--warn:#50dce5;--shadow:#18223666}html[data-theme="dark"] .topbar{background:#2f3d55}html[data-theme="dark"] .queue-panel,html[data-theme="dark"] .summary-strip article,html[data-theme="dark"] .panel,html[data-theme="dark"] table,html[data-theme="dark"] dialog{background:#40536f}.top-controls>button,.menu summary{width:2.25rem;height:2.15rem;display:inline-grid;place-items:center}.current-panel progress,.job-group progress{width:100%;height:.8rem}.dialog-body,#settings-form{min-width:min(42rem,86vw);max-width:58rem}.menu-panel button{text-align:left} diff --git a/importarr/templates/index.html b/importarr/templates/index.html index 145a904..0269f65 100644 --- a/importarr/templates/index.html +++ b/importarr/templates/index.html @@ -1,5 +1,5 @@ - + @@ -16,12 +16,14 @@ @@ -33,8 +35,20 @@
{{ status.imported_total }}Imported
{{ status.failed_total }}Failed
-
- Service info and build details +
+

Current import

idle
+

No active copy.

+ +

Waiting for an active import.

+
+
+

Queue and history

Grouped by processing state
+
Loading…
+
+ + +
+

Service info and build details

Name
{{ status.build.name }}
Version
{{ status.build.version }}
@@ -53,9 +67,11 @@
Queue mode
{{ status.control.queue_mode }}
Current job
{{ status.current or 'idle' }}
-
-
- Queue controls + + + +
+

Queue controls

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.

@@ -63,9 +79,11 @@
-
-
- Manual batches + + + +
+

Manual batches

@@ -75,15 +93,11 @@
{% for batch in batches %}{% endfor %}
IDStatusPath
{{ batch.id }}{{ batch.status }}{{ batch.path }}
-
-
-

Queue and history

Grouped by processing state
-
Loading…
-
- + + -

Settings

+

Settings

SABnzbd @@ -112,21 +126,26 @@ function actionButtons(j){ const buttons=[]; if(j.can_run_now) buttons.push(``); if(j.can_retry) buttons.push(``); if(j.can_ignore) buttons.push(``); if(j.can_remove) buttons.push(``); return buttons.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)}`; } function readiness(j){ return `${esc(j.state)}`; } - function renderJobs(data){ const groups=(data.groups||[]).filter(group=>group.jobs.length); if(!groups.length) return '

No queue items.

'; return groups.map(group=>`

${esc(group.label)} ${group.jobs.length}

${group.jobs.map(j=>``).join('')}
FileReadinessSABActions
${esc(j.name)}${jobSubtext(j)}${readiness(j)}${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}${actionButtons(j)}
${group.jobs.map(j=>`
${esc(j.name)}${jobSubtext(j)}
Readiness
${readiness(j)}
SAB
${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}
${actionButtons(j)}
`).join('')}
`).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; } } + const fmtBytes=value=>{ const bytes=Number(value||0); if(!bytes)return 'size unknown'; const units=['B','KB','MB','GB','TB']; let n=bytes,i=0; while(n>=1024&&i=10||i===0?0:1)} ${units[i]}`; }; + const fmtDuration=value=>{ const s=Math.max(0,Math.floor(Number(value||0))); const m=Math.floor(s/60); const r=s%60; return m?`${m}m ${String(r).padStart(2,'0')}s`:`${r}s`; }; + function progressBar(j){ const p=Number(j.progress_percent||0); if(!p)return ''; return `${p.toFixed(1)}% · ${fmtBytes(j.bytes_copied)} / ${fmtBytes(j.size)}`; } + function renderJobs(data){ const groups=(data.groups||[]).filter(group=>group.jobs.length); if(!groups.length) return '

No queue items.

'; return groups.map(group=>`

${esc(group.label)} ${group.jobs.length}

${group.jobs.map(j=>``).join('')}
FileReadinessSABProgressActions
${esc(j.name)}${jobSubtext(j)}${readiness(j)}${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}${progressBar(j)}${actionButtons(j)}
${group.jobs.map(j=>`
${esc(j.name)}${jobSubtext(j)}
Readiness
${readiness(j)}
SAB
${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}
Progress
${progressBar(j)||'—'}
${actionButtons(j)}
`).join('')}
`).join(''); } + function renderCurrent(control){ const current=control?.current||null; const label=typeof current==='object'?(current.file||current.name||'active copy'):current; document.getElementById('top-current-job').textContent=label||'idle'; document.getElementById('current-job').textContent=label||'idle'; document.getElementById('current-file').textContent=label?`Copying ${label}`:'No active copy.'; const progress=typeof current==='object'?Number(current.percent||0):0; document.getElementById('current-progress').value=progress; document.getElementById('current-runtime').textContent=label?(current.elapsed_seconds!==undefined?fmtDuration(current.elapsed_seconds):'running'):'idle'; document.getElementById('current-size').textContent=label?(progress?`${progress.toFixed(1)}% · ${fmtBytes(current.bytes_copied)} / ${fmtBytes(current.total_bytes)}`:fmtBytes(current.total_bytes)):'Waiting for an active import.'; } + 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('ready-state').textContent=d.control.queue_mode==='start'?'Running':d.control.queue_mode; renderCurrent(d.control); } } 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.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.querySelectorAll('[data-dialog]').forEach(button=>button.addEventListener('click',()=>{ document.querySelector('details.menu')?.removeAttribute('open'); document.getElementById(button.dataset.dialog).showModal(); })); + document.querySelectorAll('[data-close-dialog]').forEach(button=>button.addEventListener('click',()=>button.closest('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.querySelectorAll('dialog').forEach(dialog=>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.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'; } })); + const themes=['auto','light','dark']; function applyTheme(theme){ document.documentElement.dataset.theme=theme; localStorage.setItem('importarr-theme',theme); document.getElementById('theme-toggle').textContent=`Theme: ${theme}`; } document.getElementById('theme-toggle').addEventListener('click',()=>applyTheme(themes[(themes.indexOf(document.documentElement.dataset.theme)+1)%themes.length])); applyTheme(localStorage.getItem('importarr-theme')||'auto'); document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); }); - refresh(); setInterval(refresh, 10000); + refresh(); setInterval(refresh, 2000); diff --git a/tests/test_queue_controls.py b/tests/test_queue_controls.py index 313c785..e069cd7 100644 --- a/tests/test_queue_controls.py +++ b/tests/test_queue_controls.py @@ -95,6 +95,20 @@ def test_queue_item_run_now_imports_only_selected_item(tmp_path, monkeypatch): assert rows["Waiting.mkv"]["state"] == "manual_batch" +def test_current_job_status_includes_progress(tmp_path, monkeypatch): + main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch) + monkeypatch.setattr(main.time, "time", lambda: 110.0) + + main.set_current_job("Movie.mkv", bytes_copied=50, total_bytes=200, started_at=100.0) + + current = main.control_status()["current"] + assert current["file"] == "Movie.mkv" + assert current["bytes_copied"] == 50 + assert current["total_bytes"] == 200 + assert current["percent"] == 25 + assert current["elapsed_seconds"] == 10 + + def test_cancel_current_stops_before_next_manual_item(tmp_path, monkeypatch): main, download, movies, tv = configure_main(tmp_path, monkeypatch) batch = download / "Release" diff --git a/tests/test_status.py b/tests/test_status.py index 872cb58..0c7eb96 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -56,15 +56,32 @@ def test_index_packs_secondary_controls_into_menu(tmp_path, monkeypatch): assert response.status_code == 200 assert 'class="menu"' in response.text + assert 'data-dialog="service-info-dialog"' in response.text + assert 'data-dialog="queue-controls-dialog"' in response.text + assert 'data-dialog="manual-batches-dialog"' 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 "current-progress" in response.text + assert "theme-toggle" in response.text + assert "setInterval(refresh, 2000)" 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_stylesheet_includes_theme_and_progress_rules(): + from pathlib import Path + + css = Path("importarr/static/importarr.css").read_text() + + assert 'html[data-theme="light"]' in css + assert 'html[data-theme="dark"]' in css + assert ".current-panel progress" in css + assert ".top-controls>button,.menu summary" in css + + def test_index_renders_settings_dialog(tmp_path, monkeypatch): monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db")) import importarr.main as main