696 lines
28 KiB
Python
696 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from typing import Annotated
|
|
|
|
import uvicorn
|
|
import httpx
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from pydantic import BaseModel
|
|
|
|
from .build_info import build_info
|
|
from .config import Settings
|
|
from .importer import ImportCancelled, Importer
|
|
from .sabnzbd import SabnzbdClient
|
|
from .readiness import classify_history_item
|
|
from .scanner import scan_videos
|
|
from .state import State
|
|
|
|
settings = Settings.from_env()
|
|
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):
|
|
path: str
|
|
|
|
|
|
class RunNowRequest(BaseModel):
|
|
force: bool = False
|
|
|
|
|
|
class QueueControlRequest(BaseModel):
|
|
mode: str
|
|
|
|
|
|
class QueueItemActionRequest(BaseModel):
|
|
action: str
|
|
|
|
|
|
class ControlCommandResponse(BaseModel):
|
|
status: str
|
|
command: list[str]
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
|
|
|
|
class UpdateCheckResponse(BaseModel):
|
|
status: str
|
|
current_version: str
|
|
latest_version: str | None
|
|
update_available: bool
|
|
release_url: str | None = None
|
|
|
|
|
|
class AppSettingsUpdate(BaseModel):
|
|
sab_url: str
|
|
sab_api_key: str | None = None
|
|
radarr_url: str | None = None
|
|
radarr_api_key: str | None = None
|
|
sonarr_url: str | None = None
|
|
sonarr_api_key: str | None = None
|
|
|
|
|
|
class ConnectionTestRequest(BaseModel):
|
|
service: str
|
|
url: str
|
|
api_key: str | None = None
|
|
|
|
|
|
def load_ui_settings() -> None:
|
|
for key in ("sab_url", "sab_api_key", "radarr_url", "radarr_api_key", "sonarr_url", "sonarr_api_key"):
|
|
stored = state.get_app_state(key)
|
|
if stored is not None:
|
|
setattr(settings, key, stored or None)
|
|
|
|
|
|
load_ui_settings()
|
|
|
|
|
|
def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
|
if not settings.auth_token:
|
|
return
|
|
if authorization != f"Bearer {settings.auth_token}":
|
|
raise HTTPException(status_code=401, detail="write endpoint requires bearer token")
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, str]:
|
|
return {"status": "ok", "name": "Importarr", "version": build_info()["version"]}
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index(request: Request) -> HTMLResponse:
|
|
return templates.TemplateResponse(request, "index.html", {"status": status(), "batches": state.list_manual_batches()})
|
|
|
|
|
|
@app.get("/api/status")
|
|
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(),
|
|
"category": settings.sab_category,
|
|
"download_root": str(settings.download_root),
|
|
"movies_root": str(settings.movies_root),
|
|
"tv_root": str(settings.tv_root),
|
|
"sab_url": settings.sab_url,
|
|
"sab_api_key_configured": bool(settings.sab_api_key),
|
|
"radarr_url": settings.radarr_url or "",
|
|
"radarr_api_key_configured": bool(settings.radarr_api_key),
|
|
"sonarr_url": settings.sonarr_url or "",
|
|
"sonarr_api_key_configured": bool(settings.sonarr_api_key),
|
|
"auth_enabled": bool(settings.auth_token),
|
|
"bind": f"{settings.bind_host}:{settings.bind_port}",
|
|
"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,
|
|
}
|
|
|
|
|
|
@app.get("/api/settings")
|
|
def get_ui_settings() -> dict[str, object]:
|
|
return {
|
|
"sab_url": settings.sab_url,
|
|
"sab_api_key_configured": bool(settings.sab_api_key),
|
|
"radarr_url": settings.radarr_url or "",
|
|
"radarr_api_key_configured": bool(settings.radarr_api_key),
|
|
"sonarr_url": settings.sonarr_url or "",
|
|
"sonarr_api_key_configured": bool(settings.sonarr_api_key),
|
|
}
|
|
|
|
|
|
@app.post("/api/settings")
|
|
def update_ui_settings(payload: AppSettingsUpdate, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
sab_url = payload.sab_url.strip()
|
|
if not sab_url:
|
|
raise HTTPException(status_code=400, detail="SAB URL is required")
|
|
values = {
|
|
"sab_url": sab_url,
|
|
"sab_api_key": (payload.sab_api_key or "").strip(),
|
|
"radarr_url": (payload.radarr_url or "").strip(),
|
|
"radarr_api_key": (payload.radarr_api_key or "").strip(),
|
|
"sonarr_url": (payload.sonarr_url or "").strip(),
|
|
"sonarr_api_key": (payload.sonarr_api_key or "").strip(),
|
|
}
|
|
for key, value in values.items():
|
|
state.set_app_state(key, value)
|
|
setattr(settings, key, value or None)
|
|
settings.sab_url = sab_url
|
|
return get_ui_settings()
|
|
|
|
|
|
@app.post("/api/settings/test-connection")
|
|
async def test_connection(payload: ConnectionTestRequest, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
service = payload.service.strip().lower()
|
|
url = payload.url.strip().rstrip("/")
|
|
api_key = (payload.api_key or "").strip() or None
|
|
if service not in {"sabnzbd", "radarr", "sonarr"}:
|
|
raise HTTPException(status_code=400, detail="service must be sabnzbd, radarr, or sonarr")
|
|
if not url:
|
|
raise HTTPException(status_code=400, detail="URL is required")
|
|
if api_key is None:
|
|
api_key = getattr(settings, f"{service if service != 'sabnzbd' else 'sab'}_api_key")
|
|
try:
|
|
if service == "sabnzbd":
|
|
data = await SabnzbdClient(url, api_key).queue()
|
|
return {"ok": True, "service": service, "message": f"Connected to SABnzbd; {len(data.get('queue', {}).get('slots', []))} queued jobs visible."}
|
|
headers = {"X-Api-Key": api_key} if api_key else {}
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
response = await client.get(f"{url}/api/v3/system/status", headers=headers)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
name = str(data.get("appName") or service.title())
|
|
version = str(data.get("version") or "unknown version")
|
|
return {"ok": True, "service": service, "message": f"Connected to {name} {version}."}
|
|
except Exception as exc:
|
|
return {"ok": False, "service": service, "message": f"Connection failed: {exc.__class__.__name__}"}
|
|
|
|
|
|
def control_status() -> dict[str, object]:
|
|
mode = state.get_app_state("queue_mode", "running") or "running"
|
|
current = current_job_status()
|
|
cancel_requested = (state.get_app_state("cancel_requested", "false") or "false") == "true"
|
|
return {
|
|
"queue_mode": mode,
|
|
"queue_accepting_new_jobs": mode == "running",
|
|
"cancel_requested": cancel_requested,
|
|
"current": current,
|
|
}
|
|
|
|
|
|
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"
|
|
|
|
|
|
def cancel_requested() -> bool:
|
|
return (state.get_app_state("cancel_requested", "false") or "false") == "true"
|
|
|
|
|
|
def consume_cancel_request() -> bool:
|
|
if not cancel_requested():
|
|
return False
|
|
state.set_app_state("cancel_requested", "false")
|
|
return True
|
|
|
|
|
|
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, "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"}:
|
|
raise HTTPException(status_code=400, detail="mode must be running, paused, or stopped")
|
|
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()
|
|
|
|
|
|
@app.post("/api/control/start")
|
|
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)}
|
|
|
|
|
|
@app.post("/api/control/pause")
|
|
def pause_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
state.set_app_state("queue_mode", "paused")
|
|
return control_status()
|
|
|
|
|
|
@app.post("/api/control/stop")
|
|
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)}
|
|
|
|
|
|
@app.post("/api/control/cancel-current")
|
|
def cancel_current(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
state.set_app_state("cancel_requested", "true")
|
|
return control_status()
|
|
|
|
|
|
@app.post("/api/control/restart")
|
|
def restart_service(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
return _run_control_command(settings.restart_command)
|
|
|
|
|
|
@app.post("/api/control/update")
|
|
def update_service(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
update = check_update_available()
|
|
if not update["update_available"]:
|
|
return {**update, "command": settings.update_command, "stdout": "", "stderr": ""}
|
|
result = _run_control_command(settings.update_command)
|
|
return {**update, "command_result": result}
|
|
|
|
|
|
@app.get("/api/control/update-check")
|
|
def update_check(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
return check_update_available()
|
|
|
|
|
|
def check_update_available() -> dict[str, object]:
|
|
current = build_info()["version"]
|
|
try:
|
|
with httpx.Client(timeout=settings.update_check_timeout_seconds) as client:
|
|
response = client.get(settings.update_release_url, headers={"Accept": "application/json"})
|
|
response.raise_for_status()
|
|
release = response.json()
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"release check failed: {exc.__class__.__name__}") from exc
|
|
|
|
latest = str(release.get("tag_name") or release.get("name") or "").strip()
|
|
if not latest:
|
|
raise HTTPException(status_code=502, detail="release check failed: latest release has no tag_name")
|
|
|
|
payload = UpdateCheckResponse(
|
|
status="update_available" if _is_newer_version(latest, current) else "current",
|
|
current_version=current,
|
|
latest_version=latest,
|
|
update_available=_is_newer_version(latest, current),
|
|
release_url=release.get("html_url"),
|
|
)
|
|
return payload.model_dump()
|
|
|
|
|
|
def _is_newer_version(candidate: str, current: str) -> bool:
|
|
candidate_version = _version_key(candidate)
|
|
current_version = _version_key(current)
|
|
if candidate_version is None or current_version is None:
|
|
return candidate.lstrip("vV") != current.lstrip("vV") and current in {"", "development"}
|
|
return candidate_version > current_version
|
|
|
|
|
|
def _version_key(value: str) -> tuple[int, ...] | None:
|
|
normalized = value.strip().lstrip("vV").split("-", 1)[0]
|
|
parts = normalized.split(".")
|
|
if not parts or any(not part.isdigit() for part in parts):
|
|
return None
|
|
return tuple(int(part) for part in parts)
|
|
|
|
|
|
def _run_control_command(command: list[str]) -> dict[str, object]:
|
|
if not command:
|
|
raise HTTPException(status_code=500, detail="control command is not configured")
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=settings.control_command_timeout_seconds,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise HTTPException(status_code=504, detail=f"control command timed out after {exc.timeout} seconds") from exc
|
|
except OSError as exc:
|
|
raise HTTPException(status_code=500, detail=f"control command failed to start: {exc.__class__.__name__}") from exc
|
|
payload = ControlCommandResponse(
|
|
status="ok" if result.returncode == 0 else "failed",
|
|
command=command,
|
|
returncode=result.returncode,
|
|
stdout=result.stdout[-4000:],
|
|
stderr=result.stderr[-4000:],
|
|
).model_dump()
|
|
if result.returncode != 0:
|
|
raise HTTPException(status_code=500, detail=payload)
|
|
return payload
|
|
|
|
|
|
@app.get("/api/manual-batches")
|
|
def manual_batches() -> list[dict[str, object]]:
|
|
if queue_accepting_new_jobs():
|
|
sync_manual_queue()
|
|
rows = []
|
|
for batch in state.list_manual_batches():
|
|
videos = [item for item in state.list_queue_items() if item["batch_id"] == batch["id"]] if batch["status"] == "active" else []
|
|
rows.append({**batch, "videos": [{"file": item["name"], "relative_path": item["relative_path"], "size": item["size"]} for item in videos]})
|
|
return rows
|
|
|
|
|
|
@app.post("/api/manual-batches")
|
|
def add_manual_batch(payload: ManualBatchCreate, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
try:
|
|
resolved = settings.resolve_under_download_root(payload.path)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
return state.add_manual_batch(resolved)
|
|
|
|
|
|
@app.delete("/api/manual-batches/{batch_id}")
|
|
def delete_manual_batch(batch_id: int, _: None = Depends(require_write_auth)) -> dict[str, str]:
|
|
state.delete_manual_batch(batch_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@app.get("/api/history")
|
|
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 = "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), 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_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, run-now, 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()
|
|
|
|
|
|
@app.get("/api/preview")
|
|
async def preview() -> dict[str, object]:
|
|
if queue_accepting_new_jobs():
|
|
await sync_queue()
|
|
jobs = queue_jobs()
|
|
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:
|
|
sync_manual_queue()
|
|
client = SabnzbdClient(settings.sab_url, settings.sab_api_key)
|
|
try:
|
|
active = await client.active_nzo_ids()
|
|
data = await client.history()
|
|
except Exception as exc: # do not leak keys in URL/params
|
|
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", [])
|
|
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 "")
|
|
if not job_id:
|
|
continue
|
|
if readiness.ready and readiness.storage:
|
|
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:
|
|
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]]:
|
|
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"],
|
|
"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", "retrying", "waiting_for_sab"},
|
|
"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 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":
|
|
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]]:
|
|
sync_manual_queue()
|
|
return queue_jobs()
|
|
|
|
|
|
def sync_manual_queue() -> None:
|
|
if not queue_accepting_new_jobs():
|
|
return
|
|
root = settings.download_root.resolve()
|
|
for batch in state.list_manual_batches(active_only=True):
|
|
seen: set[str] = set()
|
|
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="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)
|
|
|
|
|
|
@app.post("/api/import/run-now")
|
|
async def run_now(payload: RunNowRequest | None = None, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
|
importer = Importer(settings.movies_root, settings.tv_root)
|
|
imported = 0
|
|
force = bool(payload.force) if payload else False
|
|
imported += await _import_ready_sab_jobs(importer, force=force)
|
|
imported += _import_manual_batches(importer)
|
|
return {"imported": imported}
|
|
|
|
|
|
async def _import_ready_sab_jobs(importer: Importer, force: bool = False) -> int:
|
|
client = SabnzbdClient(settings.sab_url, settings.sab_api_key)
|
|
try:
|
|
active = await client.active_nzo_ids()
|
|
data = await client.history()
|
|
except Exception:
|
|
return 0
|
|
imported = 0
|
|
for item in data.get("history", {}).get("slots", []):
|
|
if consume_cancel_request():
|
|
break
|
|
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, force_status=force, sab_storage_root=settings.sab_storage_root)
|
|
if readiness.storage is None or (not readiness.ready and not force):
|
|
continue
|
|
for video in scan_videos(readiness.storage):
|
|
if consume_cancel_request():
|
|
return imported
|
|
set_current_job(str(video.path))
|
|
try:
|
|
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
|
|
except ImportCancelled:
|
|
state.add_history(video.path, video.path, "cancelled", 0, "cancelled")
|
|
state.mark_queue_item("sab", str(video.path), "skipped", "cancelled")
|
|
return imported
|
|
except Exception as exc:
|
|
state.add_history(video.path, video.path, "failed", 0, exc.__class__.__name__)
|
|
state.mark_queue_item("sab", str(video.path), "failed", exc.__class__.__name__)
|
|
finally:
|
|
set_current_job(None)
|
|
return imported
|
|
|
|
|
|
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_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_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_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__)
|
|
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)
|
|
|
|
|
|
def _import_manual_batches(importer: Importer) -> int:
|
|
if queue_accepting_new_jobs():
|
|
sync_manual_queue()
|
|
imported = 0
|
|
for batch in state.list_manual_batches(active_only=True):
|
|
path = Path(batch["path"])
|
|
items = [item for item in state.list_queue_items() if item["source_type"] == "manual" and item["batch_id"] == batch["id"]]
|
|
for item in items:
|
|
if consume_cancel_request():
|
|
return imported
|
|
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)
|