Fix import queue UI controls #31
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user