Files
importarr/importarr/main.py
T

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)