Implement persistent queue engine Refs #6
This commit is contained in:
+85
-32
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
@@ -27,6 +29,9 @@ state = State(settings.state_path)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||
app = FastAPI(title="Importarr")
|
||||
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
|
||||
WORKER_ID = f"importarr-{os.getpid()}"
|
||||
_worker_thread: threading.Thread | None = None
|
||||
_worker_stop = threading.Event()
|
||||
|
||||
|
||||
class ManualBatchCreate(BaseModel):
|
||||
@@ -107,6 +112,10 @@ def index(request: Request) -> HTMLResponse:
|
||||
def status() -> dict[str, object]:
|
||||
history = state.list_history()
|
||||
control = control_status()
|
||||
queue_items = state.list_queue_items(active_only=False)
|
||||
queue_counts: dict[str, int] = {}
|
||||
for row in queue_items:
|
||||
queue_counts[row["state"]] = queue_counts.get(row["state"], 0) + 1
|
||||
return {
|
||||
"app": "Importarr",
|
||||
"build": build_info(),
|
||||
@@ -125,6 +134,8 @@ def status() -> dict[str, object]:
|
||||
"manual_batches": len(state.list_manual_batches(active_only=True)),
|
||||
"imported_total": sum(1 for row in history if row["status"] == "imported"),
|
||||
"failed_total": sum(1 for row in history if row["status"] == "failed"),
|
||||
"queue_total": len(queue_items),
|
||||
"queue_counts": queue_counts,
|
||||
"current": control["current"],
|
||||
"control": control,
|
||||
}
|
||||
@@ -239,11 +250,40 @@ def set_current_job(name: str | None, *, bytes_copied: int = 0, total_bytes: int
|
||||
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}),
|
||||
json.dumps({"file": name, "name": Path(name).name if name else name, "bytes_copied": bytes_copied, "total_bytes": total_bytes, "percent": percent, "started_at": started}),
|
||||
)
|
||||
return started
|
||||
|
||||
|
||||
def ensure_worker_running() -> None:
|
||||
global _worker_thread
|
||||
if _worker_thread and _worker_thread.is_alive():
|
||||
return
|
||||
_worker_stop.clear()
|
||||
state.release_stale_claims(WORKER_ID)
|
||||
_worker_thread = threading.Thread(target=_worker_loop, name="importarr-queue-worker", daemon=True)
|
||||
_worker_thread.start()
|
||||
|
||||
|
||||
def stop_worker() -> None:
|
||||
_worker_stop.set()
|
||||
|
||||
|
||||
def _worker_loop() -> None:
|
||||
importer = Importer(settings.movies_root, settings.tv_root)
|
||||
while not _worker_stop.is_set():
|
||||
try:
|
||||
if queue_accepting_new_jobs():
|
||||
sync_manual_queue()
|
||||
item = state.claim_next_queue_item(WORKER_ID) if queue_accepting_new_jobs() else None
|
||||
if item is not None:
|
||||
_import_queue_item(item, importer, from_worker=True)
|
||||
continue
|
||||
except Exception:
|
||||
state.upsert_queue_item(source_type="system", source_id="queue-worker", name="Queue worker", state="failed", reason="worker loop error")
|
||||
_worker_stop.wait(settings.poll_seconds)
|
||||
|
||||
|
||||
@app.post("/api/control/queue")
|
||||
def set_queue_control(payload: QueueControlRequest, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||
if payload.mode not in {"running", "paused", "stopped"}:
|
||||
@@ -251,6 +291,9 @@ def set_queue_control(payload: QueueControlRequest, _: None = Depends(require_wr
|
||||
state.set_app_state("queue_mode", payload.mode)
|
||||
if payload.mode == "running":
|
||||
state.set_app_state("cancel_requested", "false")
|
||||
ensure_worker_running()
|
||||
elif payload.mode == "stopped":
|
||||
stop_worker()
|
||||
return control_status()
|
||||
|
||||
|
||||
@@ -258,6 +301,7 @@ def set_queue_control(payload: QueueControlRequest, _: None = Depends(require_wr
|
||||
def start_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||
state.set_app_state("queue_mode", "running")
|
||||
state.set_app_state("cancel_requested", "false")
|
||||
ensure_worker_running()
|
||||
return {"control": control_status(), "command_result": _run_control_command(settings.start_command)}
|
||||
|
||||
|
||||
@@ -271,6 +315,7 @@ def pause_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||
def stop_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||
state.set_app_state("queue_mode", "stopped")
|
||||
state.set_app_state("cancel_requested", "true")
|
||||
stop_worker()
|
||||
return {"control": control_status(), "command_result": _run_control_command(settings.stop_command)}
|
||||
|
||||
|
||||
@@ -403,19 +448,19 @@ def queue_item_action(item_id: int, payload: QueueItemActionRequest, _: None = D
|
||||
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")
|
||||
retry_state = "ready" if item["source_type"] in {"manual", "sab"} else "detected"
|
||||
state.mark_queue_item_result(item["source_type"], item["source_id"], retry_state, "retry requested")
|
||||
elif payload.action == "run-now":
|
||||
imported = _import_queue_item(item, Importer(settings.movies_root, settings.tv_root))
|
||||
imported = _import_queue_item(item, Importer(settings.movies_root, settings.tv_root), force=True)
|
||||
updated = state.get_queue_item(item_id)
|
||||
return {"status": "imported" if imported else "updated", "imported": imported, "item": serialize_queue_item(updated or item)}
|
||||
elif payload.action == "ignore":
|
||||
state.mark_queue_item(item["source_type"], item["source_id"], "skipped", "ignored by user")
|
||||
state.mark_queue_item_result(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")
|
||||
raise HTTPException(status_code=400, detail="action must be retry, run-now, ignore, or remove")
|
||||
updated = state.get_queue_item(item_id)
|
||||
return {"status": "updated", "item": serialize_queue_item(updated or item)}
|
||||
|
||||
@@ -443,7 +488,6 @@ async def sync_queue() -> None:
|
||||
state.upsert_queue_item(source_type="system", source_id="sab-sync", name="SABnzbd", state="failed", reason=exc.__class__.__name__)
|
||||
return
|
||||
slots = data.get("history", {}).get("slots", [])
|
||||
state.delete_queue_items_by_state("sab", "ignored")
|
||||
for item in slots:
|
||||
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, sab_storage_root=settings.sab_storage_root)
|
||||
job_id = str(item.get("nzo_id") or item.get("nzoid") or item.get("name") or "")
|
||||
@@ -453,7 +497,8 @@ async def sync_queue() -> None:
|
||||
for video in scan_videos(readiness.storage):
|
||||
state.upsert_queue_item(source_type="sab", source_id=str(video.path), source_path=video.path, name=video.path.name, state="ready", reason=readiness.reason, relative_path=str(video.relative_path), size=video.size, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
|
||||
else:
|
||||
state.upsert_queue_item(source_type="sab", source_id=job_id, source_path=readiness.storage, name=str(item.get("name") or job_id), state=readiness.state, reason=readiness.reason, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
|
||||
pending_state = "waiting_for_sab" if readiness.state not in {"failed", "skipped"} else readiness.state
|
||||
state.upsert_queue_item(source_type="sab", source_id=job_id, source_path=readiness.storage, name=str(item.get("name") or job_id), state=pending_state, reason=readiness.reason, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
|
||||
|
||||
|
||||
def queue_jobs() -> list[dict[str, object]]:
|
||||
@@ -479,9 +524,11 @@ def serialize_queue_item(item: dict[str, object]) -> dict[str, object]:
|
||||
"first_seen_at": item["first_seen_at"],
|
||||
"updated_at": item["updated_at"],
|
||||
"completed_at": item["completed_at"],
|
||||
"attempt_count": item.get("attempt_count", 0),
|
||||
"claimed_by": item.get("claimed_by"),
|
||||
"sab_status": state_name if source_type == "sab" else None,
|
||||
"sab_category": item.get("sab_category") if source_type == "sab" else None,
|
||||
"can_run_now": state_name in {"ready", "manual_batch", "failed"},
|
||||
"can_run_now": state_name in {"ready", "manual_batch", "failed", "retrying", "waiting_for_sab"},
|
||||
"can_retry": state_name in {"failed", "skipped"},
|
||||
"can_ignore": state_name not in {"imported", "skipped"},
|
||||
"can_remove": True,
|
||||
@@ -491,8 +538,12 @@ def serialize_queue_item(item: dict[str, object]) -> dict[str, object]:
|
||||
def job_group(state_name: str, source_type: str) -> str:
|
||||
if source_type == "manual":
|
||||
return "manual_batch"
|
||||
if state_name in {"detected", "waiting_for_sab"}:
|
||||
return "sab_processing"
|
||||
if state_name == "ready":
|
||||
return "ready"
|
||||
if state_name == "retrying":
|
||||
return "failed"
|
||||
if state_name in {"importing", "copying"}:
|
||||
return "importing"
|
||||
if state_name == "failed":
|
||||
@@ -531,7 +582,7 @@ def sync_manual_queue() -> None:
|
||||
for video in scan_videos(Path(batch["path"])):
|
||||
source_id = str(video.path)
|
||||
seen.add(source_id)
|
||||
state.upsert_queue_item(source_type="manual", source_id=source_id, source_path=video.path, name=video.path.name, state="manual_batch", relative_path=str(video.path.relative_to(root)), size=video.size, batch_id=batch["id"])
|
||||
state.upsert_queue_item(source_type="manual", source_id=source_id, source_path=video.path, name=video.path.name, state="ready", reason="manual batch detected", relative_path=str(video.path.relative_to(root)), size=video.size, batch_id=batch["id"])
|
||||
state.remove_missing_manual_items(batch["id"], seen)
|
||||
|
||||
|
||||
@@ -581,27 +632,33 @@ async def _import_ready_sab_jobs(importer: Importer, force: bool = False) -> int
|
||||
return imported
|
||||
|
||||
|
||||
def _import_queue_item(item: dict[str, object], importer: Importer) -> int:
|
||||
if item["source_type"] not in {"sab", "manual"} or item["state"] not in {"ready", "manual_batch", "failed"}:
|
||||
def _import_queue_item(item: dict[str, object], importer: Importer, *, force: bool = False, from_worker: bool = False) -> int:
|
||||
if item["source_type"] not in {"sab", "manual"} or item["state"] not in {"ready", "manual_batch", "failed", "importing", "retrying", "waiting_for_sab"}:
|
||||
return 0
|
||||
source_path = item.get("source_path")
|
||||
if not source_path:
|
||||
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "failed", "missing source path")
|
||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "failed", "missing source path", increment_attempts=True)
|
||||
return 0
|
||||
source = Path(str(source_path))
|
||||
if not source.exists():
|
||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "failed", "missing source file", increment_attempts=True)
|
||||
return 0
|
||||
if not from_worker:
|
||||
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "importing", item.get("reason"))
|
||||
started = set_current_job(str(source))
|
||||
try:
|
||||
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")
|
||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "imported", increment_attempts=True)
|
||||
return 1
|
||||
except ImportCancelled:
|
||||
state.add_history(source, source, "cancelled", 0, "cancelled")
|
||||
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "skipped", "cancelled")
|
||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "skipped", "cancelled", increment_attempts=True)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
state.add_history(source, source, "failed", 0, exc.__class__.__name__)
|
||||
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "failed", exc.__class__.__name__)
|
||||
next_state = "retrying" if from_worker or force else "failed"
|
||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), next_state, exc.__class__.__name__, increment_attempts=True)
|
||||
return 0
|
||||
finally:
|
||||
set_current_job(None)
|
||||
@@ -617,26 +674,22 @@ def _import_manual_batches(importer: Importer) -> int:
|
||||
for item in items:
|
||||
if consume_cancel_request():
|
||||
return imported
|
||||
source = Path(item["source_path"])
|
||||
started = set_current_job(str(source))
|
||||
try:
|
||||
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
|
||||
except ImportCancelled:
|
||||
state.add_history(source, source, "cancelled", 0, "cancelled")
|
||||
state.mark_queue_item("manual", item["source_id"], "skipped", "cancelled")
|
||||
return imported
|
||||
except Exception as exc:
|
||||
state.add_history(source, source, "failed", 0, exc.__class__.__name__)
|
||||
state.mark_queue_item("manual", item["source_id"], "failed", exc.__class__.__name__)
|
||||
finally:
|
||||
set_current_job(None)
|
||||
imported += _import_queue_item(item, importer, force=True)
|
||||
if not scan_videos(path):
|
||||
state.complete_manual_batch(batch["id"])
|
||||
return imported
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_queue_worker() -> None:
|
||||
ensure_worker_running()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown_queue_worker() -> None:
|
||||
stop_worker()
|
||||
state.release_stale_claims(WORKER_ID)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
uvicorn.run("importarr.main:app", host=settings.bind_host, port=settings.bind_port, reload=False)
|
||||
|
||||
+104
-5
@@ -5,6 +5,17 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ACTIVE_QUEUE_STATES = {
|
||||
"detected",
|
||||
"waiting_for_sab",
|
||||
"ready",
|
||||
"importing",
|
||||
"retrying",
|
||||
}
|
||||
|
||||
TERMINAL_QUEUE_STATES = {"imported", "failed", "skipped"}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
@@ -47,6 +58,10 @@ class State:
|
||||
batch_id integer,
|
||||
job_id text,
|
||||
sab_category text,
|
||||
attempt_count integer not null default 0,
|
||||
last_error text,
|
||||
claimed_by text,
|
||||
claimed_at text,
|
||||
first_seen_at text not null default current_timestamp,
|
||||
updated_at text not null default current_timestamp,
|
||||
completed_at text,
|
||||
@@ -57,6 +72,14 @@ class State:
|
||||
columns = {row["name"] for row in self.conn.execute("pragma table_info(import_queue_items)")}
|
||||
if "sab_category" not in columns:
|
||||
self.conn.execute("alter table import_queue_items add column sab_category text")
|
||||
if "attempt_count" not in columns:
|
||||
self.conn.execute("alter table import_queue_items add column attempt_count integer not null default 0")
|
||||
if "last_error" not in columns:
|
||||
self.conn.execute("alter table import_queue_items add column last_error text")
|
||||
if "claimed_by" not in columns:
|
||||
self.conn.execute("alter table import_queue_items add column claimed_by text")
|
||||
if "claimed_at" not in columns:
|
||||
self.conn.execute("alter table import_queue_items add column claimed_at text")
|
||||
self.conn.commit()
|
||||
|
||||
def get_app_state(self, key: str, default: str | None = None) -> str | None:
|
||||
@@ -116,6 +139,7 @@ class State:
|
||||
batch_id: int | None = None,
|
||||
job_id: str | None = None,
|
||||
sab_category: str | None = None,
|
||||
preserve_finished_state: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
self.conn.execute(
|
||||
"""
|
||||
@@ -124,17 +148,27 @@ class State:
|
||||
on conflict(source_type, source_id) do update set
|
||||
source_path=excluded.source_path,
|
||||
name=excluded.name,
|
||||
state=excluded.state,
|
||||
reason=excluded.reason,
|
||||
state=case
|
||||
when ? and import_queue_items.state in ('imported','failed','skipped','importing','retrying') then import_queue_items.state
|
||||
else excluded.state
|
||||
end,
|
||||
reason=case
|
||||
when ? and import_queue_items.state in ('imported','failed','skipped','importing','retrying') then coalesce(import_queue_items.reason, excluded.reason)
|
||||
else excluded.reason
|
||||
end,
|
||||
relative_path=excluded.relative_path,
|
||||
size=excluded.size,
|
||||
batch_id=excluded.batch_id,
|
||||
job_id=excluded.job_id,
|
||||
sab_category=excluded.sab_category,
|
||||
updated_at=current_timestamp,
|
||||
completed_at=case when excluded.state in ('imported','failed','skipped') then current_timestamp else null end
|
||||
completed_at=case
|
||||
when ? and import_queue_items.state in ('imported','failed','skipped') then import_queue_items.completed_at
|
||||
when excluded.state in ('imported','failed','skipped') then current_timestamp
|
||||
else null
|
||||
end
|
||||
""",
|
||||
(source_type, source_id, str(source_path) if source_path else None, name, state, reason, relative_path, size, batch_id, job_id, sab_category),
|
||||
(source_type, source_id, str(source_path) if source_path else None, name, state, reason, relative_path, size, batch_id, job_id, sab_category, preserve_finished_state, preserve_finished_state, preserve_finished_state),
|
||||
)
|
||||
self.conn.commit()
|
||||
row = self.conn.execute("select * from import_queue_items where source_type = ? and source_id = ?", (source_type, source_id)).fetchone()
|
||||
@@ -142,11 +176,76 @@ class State:
|
||||
|
||||
def mark_queue_item(self, source_type: str, source_id: str, state: str, reason: str | None = None) -> None:
|
||||
self.conn.execute(
|
||||
"update import_queue_items set state=?, reason=?, updated_at=current_timestamp, completed_at=case when ? in ('imported','failed','skipped') then current_timestamp else completed_at end where source_type=? and source_id=?",
|
||||
"update import_queue_items set state=?, reason=?, updated_at=current_timestamp, claimed_by=null, claimed_at=null, completed_at=case when ? in ('imported','failed','skipped') then current_timestamp else completed_at end where source_type=? and source_id=?",
|
||||
(state, reason, state, source_type, source_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def claim_next_queue_item(self, worker_id: str) -> dict[str, Any] | None:
|
||||
with self.conn:
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
select * from import_queue_items
|
||||
where state in ('ready','retrying') and claimed_by is null
|
||||
order by
|
||||
case state when 'ready' then 0 else 1 end,
|
||||
updated_at asc,
|
||||
id asc
|
||||
limit 1
|
||||
"""
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
self.conn.execute(
|
||||
"""
|
||||
update import_queue_items
|
||||
set state='importing', claimed_by=?, claimed_at=current_timestamp, updated_at=current_timestamp
|
||||
where id=? and claimed_by is null
|
||||
""",
|
||||
(worker_id, row["id"]),
|
||||
)
|
||||
claimed = self.conn.execute("select * from import_queue_items where id = ?", (row["id"],)).fetchone()
|
||||
return dict(claimed) if claimed and claimed["claimed_by"] == worker_id else None
|
||||
|
||||
def release_stale_claims(self, worker_id: str | None = None) -> int:
|
||||
if worker_id is None:
|
||||
cursor = self.conn.execute(
|
||||
"update import_queue_items set state='retrying', claimed_by=null, claimed_at=null, updated_at=current_timestamp where state='importing'"
|
||||
)
|
||||
else:
|
||||
cursor = self.conn.execute(
|
||||
"update import_queue_items set state='retrying', claimed_by=null, claimed_at=null, updated_at=current_timestamp where state='importing' and claimed_by=?",
|
||||
(worker_id,),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
def mark_queue_item_result(
|
||||
self,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
state: str,
|
||||
reason: str | None = None,
|
||||
*,
|
||||
increment_attempts: bool = False,
|
||||
) -> None:
|
||||
self.conn.execute(
|
||||
"""
|
||||
update import_queue_items
|
||||
set state=?,
|
||||
reason=?,
|
||||
last_error=case when ? in ('failed','retrying','skipped') then ? else null end,
|
||||
attempt_count=attempt_count + ?,
|
||||
claimed_by=null,
|
||||
claimed_at=null,
|
||||
updated_at=current_timestamp,
|
||||
completed_at=case when ? in ('imported','failed','skipped') then current_timestamp else null end
|
||||
where source_type=? and source_id=?
|
||||
""",
|
||||
(state, reason, state, reason, 1 if increment_attempts else 0, state, source_type, source_id),
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<article><strong>{{ status.current or 'Idle' }}</strong><span>Current import</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.queue_total }}</strong><span>Queue items</span></article>
|
||||
</section>
|
||||
<section class="panel current-panel" aria-label="Current import details">
|
||||
<div class="section-title"><h2>Current import</h2><span id="current-runtime">idle</span></div>
|
||||
@@ -131,7 +132,7 @@
|
||||
function renderUpdateCheck(update){ const banner=document.getElementById('update-banner'); if(!update?.update_available){ banner.hidden=true; return; } document.getElementById('update-message').textContent=`↑ New version available: ${update.latest_version}`; document.getElementById('update-command').textContent=`current ${update.current_version}`; banner.hidden=false; }
|
||||
async function checkForUpdates(){ const response=await fetch('/api/control/update-check'); if(!response.ok) return; renderUpdateCheck(await response.json()); }
|
||||
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):''} · attempts ${esc(j.attempt_count||0)} · ${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>`; }
|
||||
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]}`; };
|
||||
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`; };
|
||||
|
||||
@@ -106,6 +106,7 @@ def test_queue_jobs_include_groups_and_manual_context(tmp_path, monkeypatch):
|
||||
assert jobs[0]["group"] == "manual_batch"
|
||||
assert jobs[0]["relative_path"] == "Release/Season 1/Episode.mkv"
|
||||
assert jobs[0]["can_run_now"] is True
|
||||
assert jobs[0]["state"] == "ready"
|
||||
|
||||
|
||||
def test_queue_item_retry_ignore_and_remove_actions(tmp_path, monkeypatch):
|
||||
@@ -132,8 +133,8 @@ def test_queue_item_run_now_imports_only_selected_item(tmp_path, monkeypatch):
|
||||
waiting = batch / "Waiting.mkv"
|
||||
selected.write_bytes(b"selected")
|
||||
waiting.write_bytes(b"waiting")
|
||||
selected_row = main.state.upsert_queue_item(source_type="manual", source_id=str(selected), source_path=selected, name=selected.name, state="manual_batch")
|
||||
main.state.upsert_queue_item(source_type="manual", source_id=str(waiting), source_path=waiting, name=waiting.name, state="manual_batch")
|
||||
selected_row = main.state.upsert_queue_item(source_type="manual", source_id=str(selected), source_path=selected, name=selected.name, state="ready")
|
||||
main.state.upsert_queue_item(source_type="manual", source_id=str(waiting), source_path=waiting, name=waiting.name, state="ready")
|
||||
|
||||
result = main.queue_item_action(selected_row["id"], main.QueueItemActionRequest(action="run-now"))
|
||||
|
||||
@@ -143,7 +144,7 @@ def test_queue_item_run_now_imports_only_selected_item(tmp_path, monkeypatch):
|
||||
assert waiting.exists()
|
||||
rows = {row["name"]: row for row in main.state.list_queue_items(active_only=False)}
|
||||
assert rows["Selected.mkv"]["state"] == "imported"
|
||||
assert rows["Waiting.mkv"]["state"] == "manual_batch"
|
||||
assert rows["Waiting.mkv"]["state"] == "ready"
|
||||
|
||||
|
||||
def test_current_job_status_includes_progress(tmp_path, monkeypatch):
|
||||
@@ -198,6 +199,39 @@ def test_cancel_current_stops_active_copy(tmp_path, monkeypatch):
|
||||
assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped"
|
||||
|
||||
|
||||
def test_status_includes_queue_counts(tmp_path, monkeypatch):
|
||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||
main.state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
||||
main.state.upsert_queue_item(source_type="manual", source_id="b", name="B.mkv", state="failed")
|
||||
|
||||
status = main.status()
|
||||
|
||||
assert status["queue_total"] == 2
|
||||
assert status["queue_counts"]["ready"] == 1
|
||||
assert status["queue_counts"]["failed"] == 1
|
||||
|
||||
|
||||
def test_worker_claimed_failure_retries_item(tmp_path, monkeypatch):
|
||||
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
|
||||
batch = download / "Release"
|
||||
batch.mkdir(parents=True)
|
||||
source = batch / "A.mkv"
|
||||
source.write_bytes(b"a")
|
||||
row = main.state.upsert_queue_item(source_type="manual", source_id=str(source), source_path=source, name=source.name, state="ready")
|
||||
claimed = main.state.claim_next_queue_item(main.WORKER_ID)
|
||||
|
||||
class BrokenImporter:
|
||||
def import_file(self, *args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
imported = main._import_queue_item(claimed, BrokenImporter(), from_worker=True)
|
||||
updated = main.state.get_queue_item(row["id"])
|
||||
|
||||
assert imported == 0
|
||||
assert updated["state"] == "retrying"
|
||||
assert updated["attempt_count"] == 1
|
||||
|
||||
|
||||
def test_control_update_runs_configured_command(tmp_path, monkeypatch):
|
||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||
main.settings.update_command = ["upgrade", "now"]
|
||||
|
||||
@@ -11,3 +11,37 @@ def test_delete_queue_items_by_state_can_target_reason(tmp_path):
|
||||
rows = state.list_queue_items()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["source_id"] == "other"
|
||||
|
||||
|
||||
def test_upsert_preserves_terminal_state_by_default(tmp_path):
|
||||
state = State(tmp_path / "state.db")
|
||||
state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="failed", reason="boom")
|
||||
|
||||
updated = state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready", reason="rescanned")
|
||||
|
||||
assert updated["state"] == "failed"
|
||||
assert updated["reason"] == "boom"
|
||||
|
||||
|
||||
def test_claim_next_queue_item_marks_importing(tmp_path):
|
||||
state = State(tmp_path / "state.db")
|
||||
state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
||||
|
||||
row = state.claim_next_queue_item("worker-1")
|
||||
|
||||
assert row is not None
|
||||
assert row["state"] == "importing"
|
||||
assert row["claimed_by"] == "worker-1"
|
||||
|
||||
|
||||
def test_release_stale_claims_requeues_importing_items(tmp_path):
|
||||
state = State(tmp_path / "state.db")
|
||||
state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
||||
state.claim_next_queue_item("worker-1")
|
||||
|
||||
released = state.release_stale_claims("worker-1")
|
||||
row = state.get_queue_item(1)
|
||||
|
||||
assert released == 1
|
||||
assert row["state"] == "retrying"
|
||||
assert row["claimed_by"] is None
|
||||
|
||||
Reference in New Issue
Block a user