Fix import queue UI controls #31

This commit is contained in:
2026-07-29 21:02:18 +02:00
parent 96f077ec76
commit 006db01930
6 changed files with 120 additions and 35 deletions
+9 -2
View File
@@ -27,10 +27,14 @@ class Importer:
target_root = self.tv_root if _looks_like_tv(source) else self.movies_root target_root = self.tv_root if _looks_like_tv(source) else self.movies_root
return _unique_path(target_root / source.name) 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 = self.target_for(source)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
partial = target.with_name(target.name + ".partial") partial = target.with_name(target.name + ".partial")
total = source.stat().st_size
copied = 0
if on_progress:
on_progress(copied, total)
try: try:
with source.open("rb") as src, partial.open("wb") as dst: with source.open("rb") as src, partial.open("wb") as dst:
while True: while True:
@@ -40,12 +44,15 @@ class Importer:
if not chunk: if not chunk:
break break
dst.write(chunk) dst.write(chunk)
copied += len(chunk)
if on_progress:
on_progress(copied, total)
dst.flush() dst.flush()
os.fsync(dst.fileno()) os.fsync(dst.fileno())
except ImportCancelled: except ImportCancelled:
partial.unlink(missing_ok=True) partial.unlink(missing_ok=True)
raise raise
if partial.stat().st_size != source.stat().st_size: if partial.stat().st_size != total:
raise IOError("partial copy size mismatch") raise IOError("partial copy size mismatch")
partial.rename(target) partial.rename(target)
source.unlink() source.unlink()
+35 -8
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import time
from typing import Annotated from typing import Annotated
import uvicorn import uvicorn
@@ -189,7 +191,7 @@ async def test_connection(payload: ConnectionTestRequest, _: None = Depends(requ
def control_status() -> dict[str, object]: def control_status() -> dict[str, object]:
mode = state.get_app_state("queue_mode", "running") or "running" 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" cancel_requested = (state.get_app_state("cancel_requested", "false") or "false") == "true"
return { return {
"queue_mode": mode, "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: def queue_accepting_new_jobs() -> bool:
return (state.get_app_state("queue_mode", "running") or "running") == "running" return (state.get_app_state("queue_mode", "running") or "running") == "running"
@@ -214,8 +231,17 @@ def consume_cancel_request() -> bool:
return True return True
def set_current_job(name: str | None) -> None: def set_current_job(name: str | None, *, bytes_copied: int = 0, total_bytes: int = 0, started_at: float | None = None) -> float:
state.set_app_state("current_job", name or "") 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") @app.post("/api/control/queue")
@@ -537,7 +563,8 @@ async def _import_ready_sab_jobs(importer: Importer, force: bool = False) -> int
return imported return imported
set_current_job(str(video.path)) set_current_job(str(video.path))
try: 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.add_history(result.source, result.target, "imported", result.bytes)
state.mark_queue_item("sab", str(video.path), "imported") state.mark_queue_item("sab", str(video.path), "imported")
imported += 1 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") state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "failed", "missing source path")
return 0 return 0
source = Path(str(source_path)) source = Path(str(source_path))
set_current_job(str(source)) started = set_current_job(str(source))
try: 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.add_history(result.source, result.target, "imported", result.bytes)
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "imported") state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "imported")
return 1 return 1
@@ -590,9 +617,9 @@ def _import_manual_batches(importer: Importer) -> int:
if consume_cancel_request(): if consume_cancel_request():
return imported return imported
source = Path(item["source_path"]) source = Path(item["source_path"])
set_current_job(str(source)) started = set_current_job(str(source))
try: 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.add_history(result.source, result.target, "imported", result.bytes)
state.mark_queue_item("manual", item["source_id"], "imported") state.mark_queue_item("manual", item["source_id"], "imported")
imported += 1 imported += 1
File diff suppressed because one or more lines are too long
+44 -25
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-theme="auto">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -16,12 +16,14 @@
<details class="menu"> <details class="menu">
<summary aria-label="Open menu"></summary> <summary aria-label="Open menu"></summary>
<div class="menu-panel"> <div class="menu-panel">
<button type="button" id="open-settings">Settings</button> <button type="button" data-dialog="settings-dialog">Settings</button>
<button type="button" data-dialog="service-info-dialog">Service info</button>
<button type="button" data-dialog="queue-controls-dialog">Queue controls</button>
<button type="button" data-dialog="manual-batches-dialog">Manual batches</button>
<button type="button" id="theme-toggle">Theme: auto</button>
<button type="button" data-control="stop">Stop queue</button> <button type="button" data-control="stop">Stop queue</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>
<button id="force-run" type="button">Force run now</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> </div>
</details> </details>
</div> </div>
@@ -33,8 +35,20 @@
<article><strong>{{ status.imported_total }}</strong><span>Imported</span></article> <article><strong>{{ status.imported_total }}</strong><span>Imported</span></article>
<article><strong>{{ status.failed_total }}</strong><span>Failed</span></article> <article><strong>{{ status.failed_total }}</strong><span>Failed</span></article>
</section> </section>
<details class="panel packed" id="service-info"> <section class="panel current-panel" aria-label="Current import details">
<summary>Service info and build details</summary> <div class="section-title"><h2>Current import</h2><span id="current-runtime">idle</span></div>
<p id="current-file">No active copy.</p>
<progress id="current-progress" value="0" max="100"></progress>
<p class="hint" id="current-size">Waiting for an active import.</p>
</section>
<section class="panel queue-panel">
<div class="section-title"><h2>Queue and history</h2><span>Grouped by processing state</span></div>
<div id="jobs">Loading…</div>
</section>
</main>
<dialog id="service-info-dialog">
<div class="dialog-body">
<div class="section-title"><h2>Service info and build details</h2><button type="button" data-close-dialog>Close</button></div>
<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>
@@ -53,9 +67,11 @@
<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>
</details> </div>
<details class="panel packed"> </dialog>
<summary>Queue controls</summary> <dialog id="queue-controls-dialog">
<div class="dialog-body">
<div class="section-title"><h2>Queue controls</h2><button type="button" data-close-dialog>Close</button></div>
<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>
@@ -63,9 +79,11 @@
<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>
</details> </div>
<details class="panel packed" id="manual-batches"> </dialog>
<summary>Manual batches</summary> <dialog id="manual-batches-dialog">
<div class="dialog-body">
<div class="section-title"><h2>Manual batches</h2><button type="button" data-close-dialog>Close</button></div>
<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>
@@ -75,15 +93,11 @@
<div class="table-scroll"><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></div> </tbody></table></div>
</details> </div>
<section class="panel queue-panel"> </dialog>
<div class="section-title"><h2>Queue and history</h2><span>Grouped by processing state</span></div>
<div id="jobs">Loading…</div>
</section>
</main>
<dialog id="settings-dialog"> <dialog id="settings-dialog">
<form id="settings-form" method="dialog"> <form id="settings-form" method="dialog">
<div class="section-title"><h2>Settings</h2><button type="button" id="close-settings">Close</button></div> <div class="section-title"><h2>Settings</h2><button type="button" data-close-dialog>Close</button></div>
<fieldset> <fieldset>
<legend>SABnzbd</legend> <legend>SABnzbd</legend>
<label>SAB URL <input name="sab_url" type="url" value="{{ status.sab_url }}" placeholder="http://sabnzbd:8080" required></label> <label>SAB URL <input name="sab_url" type="url" value="{{ status.sab_url }}" placeholder="http://sabnzbd:8080" required></label>
@@ -112,21 +126,26 @@
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 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 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 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 `<span class="state" title="${esc(j.reason||j.state)}">${esc(j.state)}</span>`; } 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(''); } 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<units.length-1){n/=1024;i++;} return `${n.toFixed(n>=10||i===0?0:1)} ${units[i]}`; };
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 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 `<progress value="${p}" max="100"></progress><small>${p.toFixed(1)}% · ${fmtBytes(j.bytes_copied)} / ${fmtBytes(j.size)}</small>`; }
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>Progress</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>${progressBar(j)}</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><dt>Progress</dt><dd>${progressBar(j)||'—'}</dd></dl><div class="row-actions">${actionButtons(j)}</div></article>`).join('')}</div></section>`).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.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());
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-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('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.querySelectorAll('[data-dialog]').forEach(button=>button.addEventListener('click',()=>{ document.querySelector('details.menu')?.removeAttribute('open'); document.getElementById(button.dataset.dialog).showModal(); }));
document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').close()); 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.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.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'; } }));
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(); }); 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);
</script> </script>
</body> </body>
</html> </html>
+14
View File
@@ -95,6 +95,20 @@ def test_queue_item_run_now_imports_only_selected_item(tmp_path, monkeypatch):
assert rows["Waiting.mkv"]["state"] == "manual_batch" 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): def test_cancel_current_stops_before_next_manual_item(tmp_path, monkeypatch):
main, download, movies, tv = configure_main(tmp_path, monkeypatch) main, download, movies, tv = configure_main(tmp_path, monkeypatch)
batch = download / "Release" batch = download / "Release"
+17
View File
@@ -56,15 +56,32 @@ def test_index_packs_secondary_controls_into_menu(tmp_path, monkeypatch):
assert response.status_code == 200 assert response.status_code == 200
assert 'class="menu"' in response.text 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 "Service info and build details" in response.text
assert "Queue and history" in response.text assert "Queue and history" in response.text
assert "Current import" 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 "details.menu[open]" in response.text
assert "e.target===e.currentTarget" in response.text assert "e.target===e.currentTarget" in response.text
assert "function readiness" in response.text assert "function readiness" in response.text
assert 'title="${esc(j.reason||j.state)}"' 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): 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