184 lines
7.1 KiB
Python
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)
|