From 1cafe2b45a087ade52f097aa7a7661520c2a2a85 Mon Sep 17 00:00:00 2001 From: Daniel Gradman-Svendsen Date: Wed, 29 Jul 2026 14:45:31 +0200 Subject: [PATCH] Build jobs queue UI for issue #8 --- importarr/main.py | 96 +++++++++++++++++++++++++++++----- importarr/state.py | 9 ++++ importarr/static/importarr.css | 2 +- importarr/templates/index.html | 19 ++++--- tests/test_queue_controls.py | 31 +++++++++++ 5 files changed, 136 insertions(+), 21 deletions(-) diff --git a/importarr/main.py b/importarr/main.py index be89765..289b36b 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -37,6 +37,10 @@ class QueueControlRequest(BaseModel): mode: str +class QueueItemActionRequest(BaseModel): + action: str + + def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None: if not settings.auth_token: return @@ -173,6 +177,25 @@ def history() -> list[dict[str, object]]: return state.list_history() +@app.post("/api/queue-items/{item_id}/action") +def queue_item_action(item_id: int, payload: QueueItemActionRequest, _: None = Depends(require_write_auth)) -> dict[str, object]: + item = state.get_queue_item(item_id) + if item is None: + raise HTTPException(status_code=404, detail="queue item not found") + if payload.action == "retry": + retry_state = "manual_batch" if item["source_type"] == "manual" else "ready" + state.mark_queue_item(item["source_type"], item["source_id"], retry_state, "retry requested") + elif payload.action == "ignore": + state.mark_queue_item(item["source_type"], item["source_id"], "skipped", "ignored by user") + elif payload.action == "remove": + state.delete_queue_item(item_id) + return {"status": "removed", "id": item_id} + else: + raise HTTPException(status_code=400, detail="action must be retry, ignore, or remove") + updated = state.get_queue_item(item_id) + return {"status": "updated", "item": serialize_queue_item(updated or item)} + + @app.get("/api/jobs") async def jobs() -> dict[str, object]: return await preview() @@ -183,7 +206,7 @@ async def preview() -> dict[str, object]: if queue_accepting_new_jobs(): await sync_queue() jobs = queue_jobs() - return {"sab_status": "ok", "jobs": jobs, "would_import": sum(1 for row in jobs if row["state"] in {"ready", "manual_batch"}), "control": control_status()} + return {"sab_status": "ok", "jobs": jobs, "groups": group_jobs(jobs), "would_import": sum(1 for row in jobs if row["state"] in {"ready", "manual_batch"}), "control": control_status()} async def sync_queue() -> None: @@ -209,19 +232,64 @@ async def sync_queue() -> None: def queue_jobs() -> list[dict[str, object]]: - return [ - { - "name": item["name"], - "state": item["state"], - "reason": item["reason"], - "relative_path": item["relative_path"], - "storage": item["source_path"], - "size": item["size"], - "source_type": item["source_type"], - } - for item in state.list_queue_items() - if item["source_type"] != "system" - ] + return [serialize_queue_item(item) for item in state.list_queue_items(active_only=False) if item["source_type"] != "system"] + + +def serialize_queue_item(item: dict[str, object]) -> dict[str, object]: + state_name = str(item["state"]) + source_type = str(item["source_type"]) + return { + "id": item["id"], + "name": item["name"], + "state": state_name, + "group": job_group(state_name, source_type), + "reason": item["reason"], + "relative_path": item["relative_path"], + "storage": item["source_path"], + "size": item["size"], + "source_type": source_type, + "source_id": item["source_id"], + "job_id": item["job_id"], + "batch_id": item["batch_id"], + "first_seen_at": item["first_seen_at"], + "updated_at": item["updated_at"], + "completed_at": item["completed_at"], + "sab_status": state_name if source_type == "sab" else None, + "sab_category": settings.sab_category if source_type == "sab" else None, + "can_run_now": state_name in {"ready", "manual_batch", "failed"}, + "can_retry": state_name in {"failed", "skipped"}, + "can_ignore": state_name not in {"imported", "skipped"}, + "can_remove": True, + } + + +def job_group(state_name: str, source_type: str) -> str: + if source_type == "manual": + return "manual_batch" + if state_name == "ready": + return "ready" + if state_name in {"importing", "copying"}: + return "importing" + if state_name == "failed": + return "failed" + if state_name == "skipped": + return "ignored_category" + if state_name == "imported": + return "completed" + return "sab_processing" + + +def group_jobs(jobs: list[dict[str, object]]) -> list[dict[str, object]]: + labels = { + "sab_processing": "SAB processing", + "ready": "Ready", + "importing": "Importing", + "failed": "Failed", + "ignored_category": "Ignored category", + "manual_batch": "Manual batch", + "completed": "Completed", + } + return [{"key": key, "label": label, "jobs": [job for job in jobs if job["group"] == key]} for key, label in labels.items()] def manual_batch_jobs() -> list[dict[str, object]]: diff --git a/importarr/state.py b/importarr/state.py index d5c6270..f6f598f 100644 --- a/importarr/state.py +++ b/importarr/state.py @@ -141,6 +141,15 @@ class State: ) self.conn.commit() + def delete_queue_item(self, item_id: int) -> bool: + cursor = self.conn.execute("delete from import_queue_items where id = ?", (item_id,)) + self.conn.commit() + return cursor.rowcount > 0 + + def get_queue_item(self, item_id: int) -> dict[str, Any] | None: + row = self.conn.execute("select * from import_queue_items where id = ?", (item_id,)).fetchone() + return dict(row) if row else None + def remove_missing_manual_items(self, batch_id: int, source_ids: set[str]) -> None: rows = self.conn.execute("select source_id from import_queue_items where source_type='manual' and batch_id=?", (batch_id,)).fetchall() for row in rows: diff --git a/importarr/static/importarr.css b/importarr/static/importarr.css index f42c6a8..2a4e44e 100644 --- a/importarr/static/importarr.css +++ b/importarr/static/importarr.css @@ -1 +1 @@ -body{font-family:system-ui,sans-serif;margin:0;background:#111827;color:#e5e7eb}header,main{max-width:1100px;margin:auto;padding:1rem}.topbar{display:flex;justify-content:space-between;gap:1rem;align-items:center;background:#0f172a}.build{text-align:right}.build strong{font-size:1.2rem}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:1rem}.cards article,.panel{background:#1f2937;border-radius:.75rem;padding:1rem;margin-top:1rem}strong{display:block;font-size:2rem}span,small,dd{color:#9ca3af}table{width:100%;border-collapse:collapse;background:#1f2937;margin-top:1rem}th,td{padding:.6rem;border-bottom:1px solid #374151;text-align:left}input,button{padding:.6rem;border-radius:.4rem;border:1px solid #374151}button{background:#38bdf8;color:#082f49;font-weight:700}.danger{background:#f87171;color:#450a0a}.controls{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.4rem 1rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:#0f172a;border:1px solid #374151;border-radius:999px;padding:.15rem .5rem} +body{font-family:system-ui,sans-serif;margin:0;background:#111827;color:#e5e7eb}header,main{max-width:1100px;margin:auto;padding:1rem}.topbar{display:flex;justify-content:space-between;gap:1rem;align-items:center;background:#0f172a}.build{text-align:right}.build strong{font-size:1.2rem}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:1rem}.cards article,.panel{background:#1f2937;border-radius:.75rem;padding:1rem;margin-top:1rem}strong{display:block;font-size:2rem}span,small,dd{color:#9ca3af}table{width:100%;border-collapse:collapse;background:#1f2937;margin-top:1rem}th,td{padding:.6rem;border-bottom:1px solid #374151;text-align:left;vertical-align:top}input,button{padding:.6rem;border-radius:.4rem;border:1px solid #374151}button{background:#38bdf8;color:#082f49;font-weight:700;cursor:pointer}.danger{background:#f87171;color:#450a0a}.warn{background:#fbbf24;color:#451a03}.controls,.row-actions{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.4rem 1rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:#0f172a;border:1px solid #374151;border-radius:999px;padding:.15rem .5rem;display:inline-block}.section-title{display:flex;align-items:center;justify-content:space-between;gap:1rem}.job-group{margin-top:1.25rem}.job-group h3{display:flex;gap:.5rem;align-items:center}.job-group h3 span{font-size:.9rem;border:1px solid #374151;border-radius:999px;padding:.1rem .45rem}.file-name{font-size:1rem}.row-actions button{padding:.35rem .5rem}td small{display:block;overflow-wrap:anywhere} diff --git a/importarr/templates/index.html b/importarr/templates/index.html index a3899fd..4076bd6 100644 --- a/importarr/templates/index.html +++ b/importarr/templates/index.html @@ -58,17 +58,24 @@ {% for batch in batches %}{{ batch.id }}{{ batch.status }}{{ batch.path }}{% endfor %} -
-

Jobs

Loading…
+
+

Jobs and queue

+

Rows are grouped by processing state. Failed and skipped rows can be retried; ignore and remove actions only update Importarr's queue.

+
Loading…
diff --git a/tests/test_queue_controls.py b/tests/test_queue_controls.py index 0ecc26e..6d8097f 100644 --- a/tests/test_queue_controls.py +++ b/tests/test_queue_controls.py @@ -42,6 +42,37 @@ def test_start_reenables_manual_queue_sync(tmp_path, monkeypatch): assert len(main.state.list_queue_items()) == 1 +def test_queue_jobs_include_groups_and_manual_context(tmp_path, monkeypatch): + main, download, _movies, _tv = configure_main(tmp_path, monkeypatch) + batch = download / "Release" / "Season 1" + batch.mkdir(parents=True) + (batch / "Episode.mkv").write_bytes(b"episode") + main.state.add_manual_batch(batch.parent) + + main.sync_manual_queue() + jobs = main.queue_jobs() + + assert jobs[0]["group"] == "manual_batch" + assert jobs[0]["relative_path"] == "Release/Season 1/Episode.mkv" + assert jobs[0]["can_run_now"] is True + + +def test_queue_item_retry_ignore_and_remove_actions(tmp_path, monkeypatch): + main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch) + row = main.state.upsert_queue_item(source_type="sab", source_id="job-1", name="Release", state="failed", reason="ImportError") + + retried = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="retry")) + assert retried["item"]["state"] == "ready" + assert retried["item"]["reason"] == "retry requested" + + ignored = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="ignore")) + assert ignored["item"]["state"] == "skipped" + + removed = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="remove")) + assert removed == {"status": "removed", "id": row["id"]} + assert main.state.get_queue_item(row["id"]) is None + + def test_cancel_current_stops_before_next_manual_item(tmp_path, monkeypatch): main, download, movies, tv = configure_main(tmp_path, monkeypatch) batch = download / "Release"