Files
importarr/importarr/main.py
T

184 lines
7.1 KiB
Python

from __future__ import annotations
from pathlib import Path
from typing import Annotated
import uvicorn
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 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")
class ManualBatchCreate(BaseModel):
path: str
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("index.html", {"request": request, "status": status(), "batches": state.list_manual_batches()})
@app.get("/api/status")
def status() -> dict[str, object]:
history = state.list_history()
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,
"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"),
"current": None,
}
@app.get("/api/manual-batches")
def manual_batches() -> list[dict[str, object]]:
rows = []
for batch in state.list_manual_batches():
videos = scan_videos(Path(batch["path"])) if batch["status"] == "active" else []
rows.append({**batch, "videos": [{"file": v.path.name, "relative_path": str(v.relative_path), "size": v.size} for v 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.get("/api/jobs")
async def jobs() -> dict[str, object]:
return await preview()
@app.get("/api/preview")
async def preview() -> dict[str, object]:
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
jobs = manual_batch_jobs()
return {"sab_status": "error", "error": exc.__class__.__name__, "jobs": jobs, "would_import": len(jobs)}
slots = data.get("history", {}).get("slots", [])
rows = []
for item in slots:
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root)
if readiness.ready and readiness.storage:
for video in scan_videos(readiness.storage):
rows.append({"name": video.path.name, "state": "ready", "relative_path": str(video.relative_path), "storage": str(readiness.storage), "size": video.size})
else:
rows.append({"name": item.get("name"), "state": readiness.state, "reason": readiness.reason, "storage": str(readiness.storage) if readiness.storage else None})
jobs = rows + manual_batch_jobs()
return {"sab_status": "ok", "jobs": jobs, "would_import": sum(1 for row in jobs if row["state"] in {"ready", "manual_batch"})}
def manual_batch_jobs() -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
root = settings.download_root.resolve()
for batch in state.list_manual_batches(active_only=True):
for video in scan_videos(Path(batch["path"])):
rows.append({"name": video.path.name, "state": "manual_batch", "relative_path": str(video.path.relative_to(root)), "size": video.size})
return rows
@app.post("/api/import/run-now")
async def run_now(_: None = Depends(require_write_auth)) -> dict[str, object]:
importer = Importer(settings.movies_root, settings.tv_root)
imported = 0
imported += await _import_ready_sab_jobs(importer)
imported += _import_manual_batches(importer)
return {"imported": imported}
async def _import_ready_sab_jobs(importer: Importer) -> 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", []):
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root)
if not readiness.ready or readiness.storage is None:
continue
for video in scan_videos(readiness.storage):
try:
result = importer.import_file(video.path)
state.add_history(result.source, result.target, "imported", result.bytes)
imported += 1
except Exception as exc:
state.add_history(video.path, video.path, "failed", 0, exc.__class__.__name__)
return imported
def _import_manual_batches(importer: Importer) -> int:
imported = 0
for batch in state.list_manual_batches(active_only=True):
path = Path(batch["path"])
videos = scan_videos(path)
for video in videos:
try:
result = importer.import_file(video.path)
state.add_history(result.source, result.target, "imported", result.bytes)
imported += 1
except Exception as exc:
state.add_history(video.path, video.path, "failed", 0, exc.__class__.__name__)
if not scan_videos(path):
state.complete_manual_batch(batch["id"])
return imported
def run() -> None:
uvicorn.run("importarr.main:app", host=settings.bind_host, port=settings.bind_port, reload=False)