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 @@No active copy.
+ +Waiting for an active import.
+