Author SHA1 Message Date
daniels 8a3929fae4 Add release-aware self update #25 2026-07-29 20:36:22 +02:00
daniels b2c34ef995 Add light and dark UI palettes #30 2026-07-29 20:33:00 +02:00
daniels da4df50205 Tighten Importarr UI #30 2026-07-29 20:28:09 +02:00
daniels 2a7c39bf6a Make UI SABnzbd-style #30 2026-07-29 20:19:33 +02:00
daniels 5752e9fb2f Add API update controls #22 2026-07-29 20:11:26 +02:00
daniels 6cdf1f5d49 Improve desktop jobs queue layout #29 2026-07-29 20:06:03 +02:00
daniels 7c50def0c9 Display build dates in local time 2026-07-29 20:04:37 +02:00
daniels 2156989b4b Improve mobile jobs queue #29 2026-07-29 19:52:51 +02:00
daniels d15ea13cb3 Make UI mobile responsive #29 2026-07-29 18:58:05 +02:00
daniels 5829623a9e Add per-item run now action #26 2026-07-29 15:19:31 +02:00
daniels c2ccb0d4bb Clear stale ignored SAB rows
Drop ignored SAB records before each sync so ownership remapping removes old decisions.
2026-07-29 15:16:39 +02:00
daniels f9eb633e19 Map SAB storage root safely
Only treat configured manual storage as Importarr-owned and map SAB container paths to local paths.
2026-07-29 15:15:15 +02:00
daniels ce82a405c5 Treat manual storage as Importarr-owned
Use SAB storage under the configured download root as ownership even when SAB reports category '*'.
2026-07-29 15:12:04 +02:00
daniels 22a1fc5522 Add settings connection tests 2026-07-29 15:11:26 +02:00
daniels 581934f7b5 Refresh stale SAB category ignores
Drop old ignored SAB rows before resync so fixed category parsing can take effect.
2026-07-29 15:08:32 +02:00
daniels 2ff670a9ae Read SAB cat as category
Treat SAB history cat/category fields equivalently for #20.
2026-07-29 15:06:37 +02:00
daniels 57c266dfa8 Add settings dialog for Arr connections #21 2026-07-29 14:57:38 +02:00
daniels a443909d64 Fix FastAPI index rendering
Use the current TemplateResponse signature and cover the live controls page.
2026-07-29 14:48:48 +02:00
daniels f4d151f9fe Remove legacy Importarr status UI
Run the service through the FastAPI entrypoint only to avoid split UI paths.
2026-07-29 14:46:25 +02:00
daniels 1cafe2b45a Build jobs queue UI for issue #8 2026-07-29 14:45:31 +02:00
17 changed files with 861 additions and 557 deletions
+10
View File
@@ -43,6 +43,8 @@ sudo -n sh /opt/importarr/repo-upgrade.sh
The helper refuses to run when the checkout has uncommitted changes, then performs `git pull --ff-only`, reinstalls the package from the repo, restarts `importarr.service`, and prints service status. Use it after changes have been committed and pushed to `main`.
Installed deployments can expose the same operation through the authenticated API. `GET /api/control/update-check` queries the latest release from `IMPORTARR_UPDATE_RELEASE_URL` (default: this repository's Gitea latest-release API) and compares it with the running `IMPORTARR_VERSION`. `POST /api/control/update` performs the same check and only runs the update command when a newer release tag exists. Configure `IMPORTARR_UPDATE_COMMAND` when the default `sh deploy/repo-upgrade.sh` is not correct for the service working directory, and configure `IMPORTARR_RESTART_COMMAND` when the default `systemctl restart importarr.service` needs a wrapper such as sudo.
Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.0`, ...), pushed with tags, then installed from the tagged checkout or artifact.
### Required setup
@@ -71,6 +73,14 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
- `GET /api/manual-batches`
- `POST /api/manual-batches` with `{ "path": "relative/or/absolute/path" }`
- `DELETE /api/manual-batches/{id}`
- `POST /api/control/start`
- `POST /api/control/pause`
- `POST /api/control/stop`
- `POST /api/control/cancel-current`
- `POST /api/control/restart`
- `GET /api/control/update-check`
- `POST /api/control/update`
- `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
- `POST /api/import/run-now`
Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` to require `Authorization: Bearer <token>` for write endpoints.
+3
View File
@@ -3,6 +3,9 @@ IMPORTARR_SAB_URL=http://sabnzbd:8080
# IMPORTARR_SAB_API_KEY=change-me
# IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key
IMPORTARR_SAB_CATEGORY=manual
# SAB may report storage paths from inside its container; set this when that
# differs from the local host path Importarr scans in IMPORTARR_DOWNLOAD_ROOT.
# IMPORTARR_SAB_STORAGE_ROOT=/data/downloads/manual
IMPORTARR_DOWNLOAD_ROOT=/data/downloads/manual
IMPORTARR_MOVIES_ROOT=/data/movies
IMPORTARR_TV_ROOT=/data/tv
+2 -2
View File
@@ -1,12 +1,12 @@
[Unit]
Description=Importarr manual media importer status UI
Description=Importarr manual media importer web UI
After=network-online.target
Wants=network-online.target
[Service]
EnvironmentFile=-/etc/importarr/importarr.env
EnvironmentFile=-/opt/importarr/build.env
ExecStart=/opt/importarr/venv/bin/importarr-status
ExecStart=/opt/importarr/venv/bin/importarr
Restart=on-failure
RestartSec=5s
User=root
+2
View File
@@ -32,6 +32,8 @@ fi
git fetch --prune origin
git pull --ff-only
"$VENV/bin/pip" install --upgrade "$REPO_DIR"
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
systemctl daemon-reload
GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || printf development)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
cat > "$PREFIX/build.env" <<EOF
+19 -2
View File
@@ -8,14 +8,31 @@ from . import __version__
def build_info() -> dict[str, str]:
build_date = os.getenv("IMPORTARR_BUILD_DATE", "development")
return {
"name": "Importarr",
"version": os.getenv("IMPORTARR_VERSION", __version__),
"build_date": os.getenv("IMPORTARR_BUILD_DATE", "development"),
"build_date": local_timestamp(build_date),
"git_sha": os.getenv("IMPORTARR_GIT_SHA", "development"),
"python": platform.python_version(),
"started_at": STARTED_AT,
}
STARTED_AT = datetime.now(UTC).isoformat(timespec="seconds")
def local_timestamp(value: str) -> str:
if value == "development":
return value
normalized = value.removesuffix("Z") + "+00:00" if value.endswith("Z") else value
try:
timestamp = datetime.fromisoformat(normalized)
except ValueError:
return value
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=UTC)
return timestamp.astimezone().isoformat(timespec="seconds")
STARTED_AT = datetime.now(UTC).astimezone().isoformat(timespec="seconds")
+20
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import shlex
from pathlib import Path
from pydantic import BaseModel, Field
@@ -10,6 +11,7 @@ class Settings(BaseModel):
sab_url: str = "http://sabnzbd:8080"
sab_api_key: str | None = None
sab_category: str = "manual"
sab_storage_root: Path | None = None
download_root: Path = Path("/data/downloads/manual")
movies_root: Path = Path("/data/movies")
tv_root: Path = Path("/data/tv")
@@ -20,6 +22,11 @@ class Settings(BaseModel):
sonarr_url: str | None = None
sonarr_api_key: str | None = None
auth_token: str | None = None
restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "importarr.service"])
update_command: list[str] = Field(default_factory=lambda: ["sh", "deploy/repo-upgrade.sh"])
update_release_url: str = "https://gitea.delphas.dk/api/v1/repos/daniels/importarr/releases/latest"
update_check_timeout_seconds: int = Field(default=15, ge=1)
control_command_timeout_seconds: int = Field(default=120, ge=1)
bind_host: str = "127.0.0.1"
bind_port: int = 8765
poll_seconds: int = Field(default=60, ge=5)
@@ -30,6 +37,7 @@ class Settings(BaseModel):
sab_url=os.getenv("IMPORTARR_SAB_URL", cls.model_fields["sab_url"].default),
sab_api_key=_env_secret("IMPORTARR_SAB_API_KEY"),
sab_category=os.getenv("IMPORTARR_SAB_CATEGORY", "manual"),
sab_storage_root=Path(os.getenv("IMPORTARR_SAB_STORAGE_ROOT")) if os.getenv("IMPORTARR_SAB_STORAGE_ROOT") else None,
download_root=Path(os.getenv("IMPORTARR_DOWNLOAD_ROOT", "/data/downloads/manual")),
movies_root=Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies")),
tv_root=Path(os.getenv("IMPORTARR_TV_ROOT", "/data/tv")),
@@ -40,6 +48,11 @@ class Settings(BaseModel):
sonarr_url=os.getenv("IMPORTARR_SONARR_URL"),
sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"),
auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"),
restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "importarr.service"]),
update_command=_env_command("IMPORTARR_UPDATE_COMMAND", ["sh", "deploy/repo-upgrade.sh"]),
update_release_url=os.getenv("IMPORTARR_UPDATE_RELEASE_URL", "https://gitea.delphas.dk/api/v1/repos/daniels/importarr/releases/latest"),
update_check_timeout_seconds=int(os.getenv("IMPORTARR_UPDATE_CHECK_TIMEOUT_SECONDS", "15")),
control_command_timeout_seconds=int(os.getenv("IMPORTARR_CONTROL_COMMAND_TIMEOUT_SECONDS", "120")),
bind_host=os.getenv("IMPORTARR_BIND_HOST", "127.0.0.1"),
bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")),
poll_seconds=int(os.getenv("IMPORTARR_POLL_SECONDS", "60")),
@@ -61,3 +74,10 @@ def _env_secret(name: str) -> str | None:
if file_value:
return Path(file_value).read_text(encoding="utf-8").strip()
return os.getenv(name)
def _env_command(name: str, default: list[str]) -> list[str]:
value = os.getenv(name)
if not value:
return default
return shlex.split(value)
+305 -13
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from pathlib import Path
import subprocess
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
@@ -37,6 +39,51 @@ 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
@@ -51,7 +98,7 @@ def health() -> dict[str, str]:
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("index.html", {"request": request, "status": status(), "batches": state.list_manual_batches()})
return templates.TemplateResponse(request, "index.html", {"status": status(), "batches": state.list_manual_batches()})
@app.get("/api/status")
@@ -66,6 +113,11 @@ def status() -> dict[str, object]:
"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)),
@@ -76,6 +128,65 @@ def status() -> dict[str, object]:
}
@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 = state.get_app_state("current_job")
@@ -142,6 +253,92 @@ def cancel_current(_: None = Depends(require_write_auth)) -> dict[str, object]:
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():
@@ -173,6 +370,29 @@ 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 = "manual_batch" if item["source_type"] == "manual" else "ready"
state.mark_queue_item(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))
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")
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")
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()
@@ -183,7 +403,7 @@ async def preview() -> dict[str, object]:
if queue_accepting_new_jobs():
await sync_queue()
jobs = queue_jobs()
return {"sab_status": "ok", "jobs": jobs, "would_import": sum(1 for row in jobs if row["state"] in {"ready", "manual_batch"}), "control": control_status()}
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:
@@ -196,32 +416,78 @@ 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)
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)
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)
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 ""))
def queue_jobs() -> list[dict[str, object]]:
return [
{
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": item["state"],
"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": item["source_type"],
"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"],
"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_retry": state_name in {"failed", "skipped"},
"can_ignore": state_name not in {"imported", "skipped"},
"can_remove": True,
}
for item in state.list_queue_items()
if item["source_type"] != "system"
]
def job_group(state_name: str, source_type: str) -> str:
if source_type == "manual":
return "manual_batch"
if state_name == "ready":
return "ready"
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]]:
@@ -263,7 +529,7 @@ async def _import_ready_sab_jobs(importer: Importer, force: bool = False) -> int
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)
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):
@@ -287,6 +553,32 @@ 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"}:
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")
return 0
source = Path(str(source_path))
set_current_job(str(source))
try:
result = importer.import_file(source, should_cancel=consume_cancel_request)
state.add_history(result.source, result.target, "imported", result.bytes)
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "imported")
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")
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__)
return 0
finally:
set_current_job(None)
def _import_manual_batches(importer: Importer) -> int:
if queue_accepting_new_jobs():
sync_manual_queue()
+21 -9
View File
@@ -26,25 +26,37 @@ def has_transient_part(path: Path) -> bool:
return any(part in TRANSIENT_PARTS or any(token in part for token in TRANSIENT_PARTS) for part in path.parts)
def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], category: str, download_root: Path, force_status: bool = False) -> Readiness:
def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], category: str, download_root: Path, force_status: bool = False, sab_storage_root: Path | None = None) -> Readiness:
nzo_id = str(item.get("nzo_id") or item.get("nzoid") or "")
if not force_status and nzo_id and nzo_id in active_nzo_ids:
return Readiness("processing", "SAB job is still present in queue")
if str(item.get("category") or "") != category:
return Readiness("ignored", "SAB category is not owned by Importarr")
item_category = str(item.get("category") or item.get("cat") or "")
status = str(item.get("status") or "")
storage_value = str(item.get("storage") or "")
storage = Path(storage_value).resolve() if storage_value else None
root = download_root.resolve()
sab_root = (sab_storage_root or download_root).resolve()
storage_in_local_root = bool(storage and (storage == root or root in storage.parents))
storage_in_sab_root = bool(storage and (storage == sab_root or sab_root in storage.parents))
storage_in_root = storage_in_local_root or storage_in_sab_root
if item_category != category and not storage_in_root:
return Readiness("ignored", "SAB category/storage is not owned by Importarr", storage)
if not force_status and status == "Failed":
return Readiness("failed", "SAB history reports failure")
if not force_status and (status in NOT_READY_STATUSES or status != "Completed"):
return Readiness("processing", f"SAB status is {status or 'unknown'}")
storage_value = str(item.get("storage") or "")
if not storage_value:
if storage is None:
return Readiness("unknown", "SAB completed item has no final storage")
storage = Path(storage_value).resolve()
root = download_root.resolve()
if storage != root and root not in storage.parents:
if not storage_in_root:
return Readiness("ignored", "SAB storage is outside configured download root", storage)
if storage_in_sab_root and not storage_in_local_root:
storage = root / storage.relative_to(sab_root)
if has_transient_part(storage):
return Readiness("processing", "SAB storage path contains transient unpack/admin marker", storage)
reason = "forced despite SAB status" if force_status and status != "Completed" else "SAB completed in owned category with final storage"
if force_status and status != "Completed":
reason = "forced despite SAB status"
elif item_category != category:
reason = "SAB completed inside Importarr download root"
else:
reason = "SAB completed in owned category with final storage"
return Readiness("ready", reason, storage)
+26 -3
View File
@@ -46,6 +46,7 @@ class State:
size integer not null default 0,
batch_id integer,
job_id text,
sab_category text,
first_seen_at text not null default current_timestamp,
updated_at text not null default current_timestamp,
completed_at text,
@@ -53,6 +54,9 @@ 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")
self.conn.commit()
def get_app_state(self, key: str, default: str | None = None) -> str | None:
@@ -111,11 +115,12 @@ class State:
size: int = 0,
batch_id: int | None = None,
job_id: str | None = None,
sab_category: str | None = None,
) -> dict[str, Any]:
self.conn.execute(
"""
insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id)
values (?,?,?,?,?,?,?,?,?,?)
insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id, sab_category)
values (?,?,?,?,?,?,?,?,?,?,?)
on conflict(source_type, source_id) do update set
source_path=excluded.source_path,
name=excluded.name,
@@ -125,10 +130,11 @@ class State:
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
""",
(source_type, source_id, str(source_path) if source_path else None, name, state, reason, relative_path, size, batch_id, job_id),
(source_type, source_id, str(source_path) if source_path else None, name, state, reason, relative_path, size, batch_id, job_id, sab_category),
)
self.conn.commit()
row = self.conn.execute("select * from import_queue_items where source_type = ? and source_id = ?", (source_type, source_id)).fetchone()
@@ -141,6 +147,23 @@ class State:
)
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()
return cursor.rowcount > 0
def delete_queue_items_by_state(self, source_type: str, state: str, reason: str | None = None) -> int:
if reason is None:
cursor = self.conn.execute("delete from import_queue_items where source_type = ? and state = ?", (source_type, state))
else:
cursor = self.conn.execute("delete from import_queue_items where source_type = ? and state = ? and reason = ?", (source_type, state, reason))
self.conn.commit()
return cursor.rowcount
def get_queue_item(self, item_id: int) -> dict[str, Any] | None:
row = self.conn.execute("select * from import_queue_items where id = ?", (item_id,)).fetchone()
return dict(row) if row else None
def remove_missing_manual_items(self, batch_id: int, source_ids: set[str]) -> None:
rows = self.conn.execute("select source_id from import_queue_items where source_type='manual' and batch_id=?", (batch_id,)).fetchall()
for row in rows:
File diff suppressed because one or more lines are too long
-496
View File
@@ -1,496 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import html
import json
import os
import subprocess
import time
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
SERVICE = "manual-media-import.service"
TIMER = "manual-media-import.timer"
LOG = Path("/var/log/manual-media-import.log")
IMPORTER_STATUS = Path("/run/manual-media-import/status.json")
MANUAL_BATCHES = Path("/var/lib/importarr/manual-batches.json")
QUEUE_ROOTS = {
"manual": Path(os.getenv("IMPORTARR_DOWNLOAD_ROOT", "/data/downloads/manual")),
"legacy": Path(os.getenv("IMPORTARR_LEGACY_DOWNLOAD_ROOT", "/data/downloads/legacy")),
}
VIDEO_EXT = {".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".mpg", ".mpeg", ".ts", ".m2ts", ".webm"}
SAB_CONFIG = Path(os.getenv("IMPORTARR_SABNZBD_CONFIG", "/config/sabnzbd/sabnzbd.ini"))
SAB_API = os.getenv("IMPORTARR_SABNZBD_URL", "http://sabnzbd:8080/api")
LONG_RUNTIME_SECONDS = 25 * 60
HIGH_MEMORY_BYTES = 8 * 1024**3
STALE_QUEUE_SECONDS = 6 * 60 * 60
def run(args: list[str]) -> str:
return subprocess.run(args, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout
def systemctl_show(unit: str) -> dict[str, str]:
data = {}
for line in run(["systemctl", "show", unit, "--no-pager"]).splitlines():
if "=" in line:
key, value = line.split("=", 1)
data[key] = value
return data
def int_value(value: str | None) -> int | None:
try:
return int(value or "")
except ValueError:
return None
def timestamp_to_iso(usec: str | None) -> str | None:
value = int_value(usec)
if not value or value <= 0:
return None
return datetime.fromtimestamp(value / 1_000_000, tz=timezone.utc).isoformat()
def monotonic_runtime_seconds(service: dict[str, str]) -> int | None:
started = int_value(service.get("ActiveEnterTimestampMonotonic"))
if not started:
main_pid = int_value(service.get("MainPID"))
if not main_pid:
return None
etimes = run(["ps", "-o", "etimes=", "-p", str(main_pid)]).strip()
return int_value(etimes)
boot_ns = time.clock_gettime_ns(time.CLOCK_BOOTTIME)
runtime = int((boot_ns / 1000 - started) / 1_000_000)
return max(runtime, 0)
def scan_queue(root: Path) -> dict[str, object]:
files = dirs = bytes_total = 0
processing_files = processing_dirs = processing_bytes = 0
oldest = newest = None
top_level: list[dict[str, object]] = []
if not root.exists():
return {"path": str(root), "exists": False, "files": 0, "dirs": 0, "bytes": 0, "oldest": None, "newest": None, "topLevel": [], "items": [], "processingFiles": 0, "processingDirs": 0, "processingBytes": 0, "processing": [], "processingItems": []}
top_level_map: dict[Path, dict[str, object]] = {}
processing_map: dict[Path, dict[str, object]] = {}
ready_items: list[dict[str, object]] = []
processing_items: list[dict[str, object]] = []
for dirpath, dirnames, filenames in os.walk(root):
for filename in filenames:
path = Path(dirpath) / filename
if path.suffix.lower() not in VIDEO_EXT or "sample" in filename.lower() or "sample" in str(path.parent).lower():
continue
try:
st = path.stat()
except FileNotFoundError:
continue
oldest = st.st_mtime if oldest is None else min(oldest, st.st_mtime)
newest = st.st_mtime if newest is None else max(newest, st.st_mtime)
try:
rel = path.relative_to(root)
except ValueError:
rel = path
top = root / rel.parts[0] if rel.parts else path
sab = sab_state_for(top.name)
transient = top.name.startswith(('_UNPACK_', '__UNPACK__', '_FAILED_', '_ADMIN_'))
is_processing = transient or (sab and sab.get("ready") is False and sab.get("status") != "manual")
relative_dir = str(Path(*rel.parts[:-1])) if len(rel.parts) > 1 else ""
item_label = path.name if not relative_dir else f"{relative_dir} / {path.name}"
state = str(sab.get("state") if sab else ("unpacking" if transient else "ready"))
file_item = {"name": path.name, "label": item_label, "release": top.name, "relativeDir": relative_dir, "path": str(path), "bytes": st.st_size, "mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(), "state": state}
if is_processing:
processing_files += 1
processing_bytes += st.st_size
processing_items.append(file_item)
item = processing_map.setdefault(top, {"name": top.name, "type": "dir" if top.is_dir() else "file", "files": 0, "bytes": 0, "mtime": None})
item["files"] = int(item["files"]) + 1
item["bytes"] = int(item["bytes"]) + st.st_size
item["mtime"] = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat()
continue
files += 1
bytes_total += st.st_size
ready_items.append(file_item)
item = top_level_map.setdefault(top, {"name": top.name, "type": "dir" if top.is_dir() else "file", "files": 0, "bytes": 0, "mtime": None})
item["files"] = int(item["files"]) + 1
item["bytes"] = int(item["bytes"]) + st.st_size
item["mtime"] = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat()
dirs = sum(1 for item in top_level_map.values() if item["type"] == "dir")
processing_dirs = sum(1 for item in processing_map.values() if item["type"] == "dir")
top_level = sorted(top_level_map.values(), key=lambda item: str(item["name"]).lower())
processing = sorted(processing_map.values(), key=lambda item: str(item["name"]).lower())
return {
"path": str(root),
"exists": True,
"files": files,
"dirs": dirs,
"bytes": bytes_total,
"oldest": datetime.fromtimestamp(oldest, tz=timezone.utc).isoformat() if oldest else None,
"newest": datetime.fromtimestamp(newest, tz=timezone.utc).isoformat() if newest else None,
"topLevel": top_level[:100],
"items": ready_items[:500],
"processingFiles": processing_files,
"processingDirs": processing_dirs,
"processingBytes": processing_bytes,
"processing": processing[:100],
"processingItems": processing_items[:500],
}
def read_logs(limit: int = 100) -> list[dict[str, object]]:
if not LOG.exists():
return []
lines = LOG.read_text(errors="replace").splitlines()[-max(1, min(limit, 1000)):]
records = []
for line in lines:
try:
records.append(json.loads(line))
except json.JSONDecodeError:
records.append({"level": "RAW", "msg": line})
return records
def read_summaries() -> list[dict[str, object]]:
if not LOG.exists():
return []
summaries = []
for line in LOG.read_text(errors="replace").splitlines():
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if record.get("msg") == "summary":
summaries.append(record)
return summaries
def read_importer_status() -> dict[str, object] | None:
if not IMPORTER_STATUS.exists():
return None
def infer_current_from_logs(logs: list[dict[str, object]]) -> dict[str, object] | None:
for record in reversed(logs):
if record.get("level") != "MOVE" or record.get("msg") not in {"moving", "moving sidecar"}:
continue
src = record.get("src")
dest = record.get("dest")
if not src or not dest:
continue
src_path = Path(str(src))
partial = Path(str(dest) + ".partial")
total = None
copied = None
try:
total = src_path.stat().st_size
except FileNotFoundError:
pass
try:
copied = partial.stat().st_size
except FileNotFoundError:
copied = None
percent = round((copied / total * 100), 2) if copied is not None and total else None
return {"phase": "copying", "src": str(src), "dest": str(dest), "partial": str(partial), "bytes_copied": copied, "bytes_total": total, "percent": percent, "media_type": record.get("media_type"), "source_tag": record.get("source_tag"), "kind": "inferred"}
return None
try:
return json.loads(IMPORTER_STATUS.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def queue_status() -> dict[str, object]:
global _SAB_HISTORY_CACHE
_SAB_HISTORY_CACHE = None
roots = {name: scan_queue(path) for name, path in QUEUE_ROOTS.items()}
return {
"roots": roots,
"files": sum(int(r["files"]) for r in roots.values()),
"dirs": sum(int(r["dirs"]) for r in roots.values()),
"bytes": sum(int(r["bytes"]) for r in roots.values()),
"processingFiles": sum(int(r["processingFiles"]) for r in roots.values()),
"processingDirs": sum(int(r["processingDirs"]) for r in roots.values()),
"processingBytes": sum(int(r["processingBytes"]) for r in roots.values()),
}
def sab_api_key() -> str | None:
try:
import re
m = re.search(r"^api_key\s*=\s*(\S+)", SAB_CONFIG.read_text(errors="replace"), re.M)
return m.group(1) if m else None
except OSError:
return None
def sab_history() -> list[dict[str, object]]:
key = sab_api_key()
if not key:
return []
try:
q = urllib.parse.urlencode({"mode": "history", "output": "json", "limit": 200, "apikey": key})
data = json.load(urllib.request.urlopen(f"{SAB_API}?{q}", timeout=10))
return data.get("history", {}).get("slots", [])
except Exception:
return []
_SAB_HISTORY_CACHE: list[dict[str, object]] | None = None
def sab_state_for(folder_name: str) -> dict[str, object] | None:
global _SAB_HISTORY_CACHE
if _SAB_HISTORY_CACHE is None:
_SAB_HISTORY_CACHE = sab_history()
normalized = folder_name.removeprefix("_UNPACK_").removeprefix("__UNPACK__")
for item in _SAB_HISTORY_CACHE:
name = str(item.get("name") or "")
if name != normalized and name != folder_name:
continue
status = str(item.get("status") or "")
storage = str(item.get("storage") or "")
action = str(item.get("action_line") or "")
category = str(item.get("category") or item.get("cat") or "")
owned = category == "manual"
ready = owned and status == "Completed" and bool(storage) and "_UNPACK_" not in storage
state = "ready" if ready else ("ignored category " + category if not owned else (status.lower() if status else "sab pending"))
if action:
state = action
return {"ready": ready, "owned": owned, "category": category, "status": status, "storage": storage, "state": state}
return None
def read_manual_batches() -> list[str]:
try:
raw = json.loads(MANUAL_BATCHES.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
return [str(x) for x in raw] if isinstance(raw, list) else []
def write_manual_batches(items: list[str]) -> None:
MANUAL_BATCHES.parent.mkdir(parents=True, exist_ok=True)
MANUAL_BATCHES.write_text(json.dumps(sorted(set(items)), indent=2), encoding="utf-8")
def add_manual_batch(value: str) -> tuple[bool, str]:
value = value.strip().strip("/")
if not value:
return False, "missing folder"
base = QUEUE_ROOTS["manual"].resolve()
path = (base / value).resolve() if not value.startswith("/srv/") else Path(value).resolve()
if not (path == base or path.is_relative_to(base)):
return False, "folder must be under manual downloads"
if not path.is_dir():
return False, "folder does not exist"
items = read_manual_batches()
items.append(str(path))
write_manual_batches(items)
return True, str(path)
def status() -> dict[str, object]:
service = systemctl_show(SERVICE)
timer = systemctl_show(TIMER)
logs = read_logs(300)
all_logs = read_logs(1000)
summaries = read_summaries()
last_summary = summaries[-1] if summaries else None
cutoff_1h = datetime.now() - timedelta(hours=1)
cutoff_24h = datetime.now() - timedelta(hours=24)
processed_1h = 0
processed_24h = 0
processed_total = 0
runs_1h = 0
runs_24h = 0
runs_total = 0
for summary in summaries:
moved = int(summary.get("moved") or 0)
processed_total += moved
runs_total += 1
try:
ts = datetime.fromisoformat(str(summary.get("ts")))
except ValueError:
ts = None
if ts and ts >= cutoff_1h:
processed_1h += moved
runs_1h += 1
if ts and ts >= cutoff_24h:
processed_24h += moved
runs_24h += 1
queue = queue_status()
running = service.get("ActiveState") == "activating" or service.get("SubState") in {"start", "running"}
current = read_importer_status()
if running and (not current or current.get("phase") == "done"):
current = infer_current_from_logs(all_logs)
runtime = monotonic_runtime_seconds(service) if running else None
memory_current = int_value(service.get("MemoryCurrent"))
memory_peak = int_value(service.get("MemoryPeak"))
warnings = []
if runtime and runtime > LONG_RUNTIME_SECONDS:
warnings.append("manual-media-import.service has been running longer than 25 minutes")
if memory_peak and memory_peak > HIGH_MEMORY_BYTES:
warnings.append("manual-media-import.service peak memory is over 8 GiB")
if last_summary and int(last_summary.get("errors") or 0) > 0:
warnings.append("last importer summary reported errors")
now = time.time()
for name, root in queue["roots"].items():
oldest = root.get("oldest")
if oldest:
try:
age = now - datetime.fromisoformat(str(oldest)).timestamp()
if age > STALE_QUEUE_SECONDS:
warnings.append(f"{name} queue contains files older than 6 hours")
except ValueError:
pass
health_state = "warning" if warnings else ("running" if running else service.get("Result", "unknown"))
return {
"name": "Importarr",
"service": {
"unit": SERVICE,
"activeState": service.get("ActiveState"),
"subState": service.get("SubState"),
"result": service.get("Result"),
"running": running,
"mainPid": int_value(service.get("MainPID")),
"startedAt": timestamp_to_iso(service.get("ActiveEnterTimestampUSec")),
"runtimeSeconds": runtime,
"memoryCurrentBytes": memory_current,
"memoryPeakBytes": memory_peak,
},
"timer": {
"unit": TIMER,
"activeState": timer.get("ActiveState"),
"subState": timer.get("SubState"),
"lastTrigger": timestamp_to_iso(timer.get("LastTriggerUSec")),
"nextElapse": timestamp_to_iso(timer.get("NextElapseUSecRealtime")),
},
"queue": queue,
"lastSummary": last_summary,
"processed": {"last1h": processed_1h, "last24h": processed_24h, "total": processed_total, "runsLast1h": runs_1h, "runsLast24h": runs_24h, "runsTotal": runs_total},
"current": current,
"manualBatches": read_manual_batches(),
"health": {"state": health_state, "warnings": warnings},
}
def fast_health() -> dict[str, object]:
service = systemctl_show(SERVICE)
timer = systemctl_show(TIMER)
running = service.get("ActiveState") == "activating" or service.get("SubState") in {"start", "running"}
runtime = monotonic_runtime_seconds(service) if running else None
warnings = []
if runtime and runtime > LONG_RUNTIME_SECONDS:
warnings.append("manual-media-import.service has been running longer than 25 minutes")
return {"state": "warning" if warnings else ("running" if running else service.get("Result", "unknown")), "warnings": warnings, "service": service.get("ActiveState"), "timer": timer.get("ActiveState")}
def page() -> bytes:
s = status()
logs = read_logs(80)
def esc(value: object) -> str:
return html.escape("" if value is None else str(value))
def gib(value: object) -> str:
return "" if value is None else f"{int(value) / 1024**3:.2f} GiB"
def secs(value: object) -> str:
if value is None:
return ""
seconds = int(value)
return f"{seconds // 60}m {seconds % 60}s"
def job_name(path: object) -> str:
if not path:
return ""
name = Path(str(path)).name
return name[:37] + "..." if len(name) > 40 else name
title_icon = "📥"
title_runtime = secs(s["service"]["runtimeSeconds"])
current = s.get("current") or {}
progress = current.get("percent")
full_current_name = Path(str(current.get("src", ""))).name if current.get("src") else ""
current_src_path = Path(str(current.get("src", ""))) if current.get("src") else None
current_top_name = ""
current_relative_dir = ""
if current_src_path:
for root in QUEUE_ROOTS.values():
try:
rel = current_src_path.relative_to(root)
current_top_name = rel.parts[0] if rel.parts else current_src_path.name
current_relative_dir = str(Path(*rel.parts[:-1])) if len(rel.parts) > 1 else ""
break
except ValueError:
continue
current_name = job_name(current.get("src"))
title = f"{title_icon} {progress}% {title_runtime} - {current_name}" if progress is not None and current_name else f"{title_icon} - idle"
warnings = s["health"]["warnings"]
warning_html = "".join(f"<p class='warn'>{esc(w)}</p>" for w in warnings) or "<p class='ok'>None</p>"
log_text = "\n".join(esc(f"[{r.get('ts','')}] {r.get('level','')} {r.get('msg','')} {json.dumps(r, ensure_ascii=False)}") for r in logs)
source_target_html = f"<p class='tiny'><span>Source:</span> {esc(current.get('src'))}</p><p class='tiny'><span>Target:</span> {esc(current.get('dest'))}</p>" if current else ""
progress_html = f"<p class='filename'><strong>{esc(full_current_name)}</strong><br><span class='muted'>{esc(current_relative_dir)}</span></p><p>{esc(progress)}% · runtime {title_runtime}</p><progress max='100' value='{esc(progress or 0)}'></progress><p>{esc(current.get('phase'))} · {gib(current.get('bytes_copied'))} / {gib(current.get('bytes_total'))}</p>{source_target_html}" if current else "<p>—</p>"
current_row = f"<tr class='active'><td>▶</td><td class='filename'><strong>{esc(full_current_name)}</strong><br><span class='muted'>{esc(current_relative_dir)}</span></td><td>{esc(progress)}%</td><td><progress max='100' value='{esc(progress or 0)}'></progress></td><td>{title_runtime}</td></tr>" if current else ""
current_path = str(current_src_path) if current_src_path else ""
processing_items = [item for item in s["queue"]["roots"]["manual"]["processingItems"] if item["path"] != current_path]
ready_items = [item for item in s["queue"]["roots"]["manual"]["items"] if item["path"] != current_path]
processing_rows = "".join(f"<tr class='processing'><td>⏳</td><td class='filename'><strong>{esc(item['name'])}</strong><br><span class='muted'>{esc(item.get('relativeDir') or item['release'])}</span></td><td>unpacking</td><td>{gib(item.get('bytes'))}</td><td>waiting</td></tr>" for item in processing_items[:30])
ready_rows = "".join(f"<tr><td>◷</td><td class='filename'><strong>{esc(item['name'])}</strong><br><span class='muted'>{esc(item.get('relativeDir') or item['release'])}</span></td><td>ready</td><td>{gib(item.get('bytes'))}</td><td>queued</td></tr>" for item in ready_items[:30])
queue_rows = processing_rows + ready_rows or "<tr><td>✓</td><td colspan='4'>No video files waiting</td></tr>"
history = [r for r in reversed(logs) if r.get("level") == "MOVE" and r.get("msg") == "moving"][:12]
history_rows = "".join(f"<tr><td>✓</td><td class='filename'>{esc(Path(str(r.get('dest',''))).name)}</td><td>{esc(r.get('media_type',''))}</td><td colspan='2'>{esc(r.get('ts',''))}</td></tr>" for r in history) or "<tr><td>—</td><td colspan='4'>No recent imports</td></tr>"
body = f"""<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'><meta http-equiv='refresh' content='10'><title>{esc(title)}</title><style>body{{font-family:system-ui,sans-serif;margin:0;background:#111;color:#eee}}header{{display:flex;align-items:center;gap:1rem;background:#3b3b3b;padding:.7rem 1.2rem;border-bottom:1px solid #111;flex-wrap:wrap}}header h1{{margin:0;font-size:1.5rem}}.pill{{background:#222;border:1px solid #555;padding:.35rem .7rem}}main{{padding:1rem}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem}}.card{{background:#1d1d1d;border:1px solid #333;border-radius:12px;padding:1rem;margin-bottom:1rem;min-width:0;overflow:hidden}}input,button{{padding:.55rem;border:1px solid #555;background:#2b2b2b;color:#eee}}button{{cursor:pointer}}.filename{{overflow-wrap:anywhere;word-break:break-word}}.tiny{{font-size:.78rem;color:#999;line-height:1.25;overflow-wrap:anywhere}}.tiny span{{color:#bbb}}.ok{{color:#60d394}}.warn{{color:#ffd166}}progress{{width:100%;height:1.2rem;accent-color:#7fd37f}}table{{width:100%;border-collapse:collapse;background:#2b2b2b}}th,td{{padding:.65rem;border-bottom:1px solid #111;text-align:left;vertical-align:middle}}th{{background:#444;color:#ddd}}tr:nth-child(even){{background:#333}}tr.active{{background:#3f4a3f}}tr.processing{{background:#4a4232}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;max-height:28rem;overflow:auto}}.muted{{color:#aaa}}a{{color:#8ecae6}}</style></head><body><header><h1>📥 Importarr</h1><span class='pill'>{esc(s['queue']['files'])} videos ready</span><span class='pill'>{esc(s['queue']['processingFiles'])} unpacking</span><span class='pill'>ready {gib(s['queue']['bytes'])}</span><span class='pill'>1h {esc(s['processed']['last1h'])} · 24h {esc(s['processed']['last24h'])} · total {esc(s['processed']['total'])}</span></header><main><div class='grid'><section class='card'><h2>Status</h2><p class='{('warn' if warnings else 'ok')}'>{esc(s['health']['state'])}</p><p>Service: {esc(s['service']['activeState'])}/{esc(s['service']['subState'])}</p><p>Runtime: {secs(s['service']['runtimeSeconds'])}</p><p>Memory current: {gib(s['service']['memoryCurrentBytes'])}</p><p>Memory peak: {gib(s['service']['memoryPeakBytes'])}</p><p class='muted'>Scheduled automatically every 15 minutes. `_UNPACK_` folders are shown as unpacking, not ready.</p></section><section class='card'><h2>Current file</h2>{progress_html}</section><section class='card'><h2>Processed</h2><p>Last 1h: {esc(s['processed']['last1h'])} imported</p><p>Last 24h: {esc(s['processed']['last24h'])} imported</p><p>Total: {esc(s['processed']['total'])} imported</p></section><section class='card'><h2>Warnings</h2>{warning_html}</section></div><section class='card'><h2>Add manual batch</h2><form onsubmit="event.preventDefault();fetch('/api/manual-batches',{{method:'POST',headers:{{'content-type':'application/json'}},body:JSON.stringify({{path:this.path.value}})}}).then(()=>location.reload())"><input name='path' placeholder='folder under manual downloads' size='60'><button>Add folder once</button></form></section><section class='card'><h2>Jobs</h2><table><thead><tr><th></th><th>Name</th><th>State</th><th>Progress / Size</th><th>Runtime</th></tr></thead><tbody>{current_row}{queue_rows}</tbody></table></section><section class='card'><h2>History</h2><table><thead><tr><th></th><th>Name</th><th>Type</th><th colspan='2'>Time</th></tr></thead><tbody>{history_rows}</tbody></table></section><section class='card'><h2>Recent log</h2><pre>{log_text}</pre></section><p><a href='/api/status'>/api/status</a> · <a href='/api/queue'>/api/queue</a> · <a href='/api/logs?limit=100'>/api/logs</a></p></main></body></html>"""
return body.encode()
class Handler(BaseHTTPRequestHandler):
def send(self, code: int, content_type: str, data: bytes) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def do_GET(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/":
self.send(200, "text/html; charset=utf-8", page())
elif parsed.path == "/api/status":
self.send(200, "application/json", json.dumps(status()).encode())
elif parsed.path == "/api/queue":
self.send(200, "application/json", json.dumps(queue_status()).encode())
elif parsed.path == "/api/logs":
params = urllib.parse.parse_qs(parsed.query)
limit = int(params.get("limit", ["100"])[0])
self.send(200, "application/json", json.dumps(read_logs(limit)).encode())
elif parsed.path == "/health":
self.send(200, "application/json", json.dumps(fast_health()).encode())
else:
self.send(404, "text/plain", b"not found")
def do_POST(self) -> None:
parsed = urllib.parse.urlparse(self.path)
if parsed.path != "/api/manual-batches":
self.send(404, "text/plain", b"not found")
return
length = int(self.headers.get("content-length") or 0)
try:
payload = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
self.send(400, "application/json", json.dumps({"ok": False, "error": "invalid json"}).encode())
return
ok, message = add_manual_batch(str(payload.get("path") or ""))
self.send(200 if ok else 400, "application/json", json.dumps({"ok": ok, "result": message}).encode())
def log_message(self, fmt: str, *args: object) -> None:
return
def main() -> None:
host = os.getenv("IMPORTARR_BIND_HOST", "0.0.0.0")
port = int(os.getenv("IMPORTARR_BIND_PORT", "8095"))
ThreadingHTTPServer((host, port), Handler).serve_forever()
if __name__ == "__main__":
main()
+81 -24
View File
@@ -8,18 +8,33 @@
</head>
<body>
<header class="topbar">
<div><h1>Importarr</h1><p>Manual SABnzbd imports, safely gated by SAB completion.</p></div>
<div class="build"><strong>{{ status.build.version }}</strong><span>{{ status.build.git_sha[:12] }} · {{ status.build.build_date }}</span></div>
<div class="brand"><h1>Importarr</h1><p id="ready-state">{{ 'Running' if status.control.queue_mode == 'start' else status.control.queue_mode|capitalize }}</p></div>
<div class="top-status"><span>Current</span><strong id="top-current-job">{{ status.current or 'idle' }}</strong></div>
<div class="top-controls">
<button type="button" data-control="start" aria-label="Start imports"></button>
<button type="button" data-control="pause" aria-label="Pause imports"></button>
<details class="menu">
<summary aria-label="Open menu"></summary>
<div class="menu-panel">
<button type="button" id="open-settings">Settings</button>
<button type="button" data-control="stop">Stop queue</button>
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
<button id="force-run" type="button">Force run now</button>
<a href="#manual-batches">Manual batches</a>
<a href="#service-info">Service info</a>
</div>
</details>
</div>
</header>
<main>
<section class="cards">
<article><strong>{{ status.imported_total }}</strong><span>Imported total</span></article>
<article><strong>{{ status.failed_total }}</strong><span>Failed total</span></article>
<article><strong>{{ status.manual_batches }}</strong><span>Manual batches</span></article>
<article><strong>{{ status.category }}</strong><span>SAB category</span></article>
<section class="summary-strip" aria-label="Importarr summary">
<article><strong>{{ status.control.queue_mode }}</strong><span>Queue mode</span></article>
<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>
</section>
<section class="panel">
<h2>Service info</h2>
<details class="panel packed" id="service-info">
<summary>Service info and build details</summary>
<dl class="info">
<dt>Name</dt><dd>{{ status.build.name }}</dd>
<dt>Version</dt><dd>{{ status.build.version }}</dd>
@@ -28,6 +43,9 @@
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
<dt>Python</dt><dd>{{ status.build.python }}</dd>
<dt>SAB URL</dt><dd>{{ status.sab_url }}</dd>
<dt>SAB API token</dt><dd id="sab-token-status">{{ 'configured' if status.sab_api_key_configured else 'not configured' }}</dd>
<dt>Radarr</dt><dd>{{ status.radarr_url or 'not configured' }}</dd>
<dt>Sonarr</dt><dd>{{ status.sonarr_url or 'not configured' }}</dd>
<dt>Download root</dt><dd>{{ status.download_root }}</dd>
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
<dt>TV root</dt><dd>{{ status.tv_root }}</dd>
@@ -35,9 +53,9 @@
<dt>Queue mode</dt><dd id="queue-mode">{{ status.control.queue_mode }}</dd>
<dt>Current job</dt><dd id="current-job">{{ status.current or 'idle' }}</dd>
</dl>
</section>
<section class="panel">
<h2>Queue controls</h2>
</details>
<details class="panel packed">
<summary>Queue controls</summary>
<p>Pause and stop prevent new jobs from being added to the queue. They do not interrupt an import already in progress; use cancel current job for that.</p>
<div class="controls">
<button type="button" data-control="start">Start</button>
@@ -45,30 +63,69 @@
<button type="button" data-control="stop">Stop</button>
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
</div>
</section>
<section>
<h2>Manual batches</h2>
</details>
<details class="panel packed" id="manual-batches">
<summary>Manual batches</summary>
<form id="batch-form" class="inline-form">
<input name="path" placeholder="folder under download root">
<input id="batch-picker" type="file" webkitdirectory directory multiple hidden>
<button type="button" id="browse-batch">Browse…</button>
<button>Add batch</button>
</form>
<table><thead><tr><th>ID</th><th>Status</th><th>Path</th></tr></thead><tbody>
<div class="table-scroll"><table><thead><tr><th>ID</th><th>Status</th><th>Path</th></tr></thead><tbody>
{% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %}
</tbody></table>
</section>
<section>
<h2>Jobs</h2><button id="force-run" type="button">Force run now</button><div id="jobs">Loading…</div>
</tbody></table></div>
</details>
<section class="panel queue-panel">
<div class="section-title"><h2>Queue and history</h2><span>Grouped by processing state</span></div>
<div id="jobs">Loading…</div>
</section>
</main>
<dialog id="settings-dialog">
<form id="settings-form" method="dialog">
<div class="section-title"><h2>Settings</h2><button type="button" id="close-settings">Close</button></div>
<fieldset>
<legend>SABnzbd</legend>
<label>SAB URL <input name="sab_url" type="url" value="{{ status.sab_url }}" placeholder="http://sabnzbd:8080" required></label>
<label>API token <input name="sab_api_key" type="password" placeholder="{% if status.sab_api_key_configured %}Configured; enter a new token to replace{% else %}SAB API token{% endif %}" autocomplete="off"></label>
<button type="button" data-test-connection="sabnzbd">Test SABnzbd connection</button><output id="sabnzbd-test-result"></output>
</fieldset>
<fieldset>
<legend>Radarr</legend>
<label>Radarr URL <input name="radarr_url" type="url" value="{{ status.radarr_url }}" placeholder="http://radarr:7878"></label>
<label>API token <input name="radarr_api_key" type="password" placeholder="{% if status.radarr_api_key_configured %}Configured; enter a new token to replace{% else %}Radarr API token{% endif %}" autocomplete="off"></label>
<button type="button" data-test-connection="radarr">Test Radarr connection</button><output id="radarr-test-result"></output>
</fieldset>
<fieldset>
<legend>Sonarr</legend>
<label>Sonarr URL <input name="sonarr_url" type="url" value="{{ status.sonarr_url }}" placeholder="http://sonarr:8989"></label>
<label>API token <input name="sonarr_api_key" type="password" placeholder="{% if status.sonarr_api_key_configured %}Configured; enter a new token to replace{% else %}Sonarr API token{% endif %}" autocomplete="off"></label>
<button type="button" data-test-connection="sonarr">Test Sonarr connection</button><output id="sonarr-test-result"></output>
</fieldset>
<p class="hint">Blank token fields clear the stored token. Environment values remain the startup defaults until saved here.</p>
<button type="submit">Save settings</button>
</form>
</dialog>
<script>
async function refresh(){ const r=await fetch('/api/jobs'); const d=await r.json(); document.title=d.jobs.length?`📥 ${d.jobs.length} jobs - Importarr`:'📥 - idle · Importarr'; document.getElementById('jobs').innerHTML='<table><tr><th>Name</th><th>State</th><th>Context</th></tr>'+d.jobs.map(j=>`<tr><td>${j.name||''}</td><td><span class="state">${j.state}</span></td><td><small>${j.relative_path||j.storage||j.reason||''}</small></td></tr>`).join('')+'</table>'; if(d.control){ document.getElementById('queue-mode').textContent=d.control.queue_mode; document.getElementById('current-job').textContent=d.control.current||'idle'; } }
document.querySelectorAll('[data-control]').forEach(button=>button.addEventListener('click', async()=>{ await fetch(`/api/control/${button.dataset.control}`,{method:'POST'}); await refresh(); }));
const esc=value=>String(value??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
async function postJson(url, body){ const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:body?JSON.stringify(body):undefined}); if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); alert(error.detail||'Request failed'); } return response; }
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 readiness(j){ return `<span class="state" title="${esc(j.reason||j.state)}">${esc(j.state)}</span>`; }
function renderJobs(data){ const groups=(data.groups||[]).filter(group=>group.jobs.length); if(!groups.length) return '<p>No queue items.</p>'; return groups.map(group=>`<section class="job-group"><h3>${esc(group.label)} <span>${group.jobs.length}</span></h3><div class="table-scroll jobs-table"><table><thead><tr><th>File</th><th>Readiness</th><th>SAB</th><th>Actions</th></tr></thead><tbody>${group.jobs.map(j=>`<tr><td><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small></td><td>${readiness(j)}</td><td><small>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</small></td><td class="row-actions">${actionButtons(j)}</td></tr>`).join('')}</tbody></table></div><div class="job-cards">${group.jobs.map(j=>`<article class="job-card"><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small><dl><dt>Readiness</dt><dd>${readiness(j)}</dd><dt>SAB</dt><dd>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</dd></dl><div class="row-actions">${actionButtons(j)}</div></article>`).join('')}</div></section>`).join(''); }
async function refresh(){ const r=await fetch('/api/jobs'); const d=await r.json(); document.title=d.jobs.length?`📥 ${d.jobs.length} jobs - Importarr`:'📥 - idle · Importarr'; document.getElementById('jobs').innerHTML=renderJobs(d); if(d.control){ const current=d.control.current||'idle'; document.getElementById('queue-mode').textContent=d.control.queue_mode; document.getElementById('current-job').textContent=current; document.getElementById('top-current-job').textContent=current; document.getElementById('ready-state').textContent=d.control.queue_mode==='start'?'Running':d.control.queue_mode; } }
document.getElementById('jobs').addEventListener('click', async e=>{ const button=e.target.closest('button[data-action]'); if(!button)return; const action=button.dataset.action; const destructive=['ignore','remove'].includes(action); if(destructive&&!confirm(`${action} this Importarr queue item?`)) return; await postJson(`/api/queue-items/${button.dataset.id}/action`,{action}); await refresh(); });
document.querySelectorAll('[data-control]').forEach(button=>button.addEventListener('click', async()=>{ if(button.dataset.control==='cancel-current'&&!confirm('Cancel the current import job?')) return; await postJson(`/api/control/${button.dataset.control}`); await refresh(); }));
document.getElementById('browse-batch').addEventListener('click',()=>document.getElementById('batch-picker').click());
document.getElementById('batch-picker').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f)return; const top=(f.webkitRelativePath||'').split('/')[0]; if(top) document.querySelector('#batch-form [name="path"]').value=top; });
document.getElementById('batch-form').addEventListener('submit', async e=>{ e.preventDefault(); await fetch('/api/manual-batches',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:e.target.path.value})}); location.reload(); });
document.getElementById('force-run').addEventListener('click', async()=>{ await fetch('/api/import/run-now',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({force:true})}); await refresh(); });
document.getElementById('batch-form').addEventListener('submit', async e=>{ e.preventDefault(); const response=await postJson('/api/manual-batches',{path:e.target.path.value}); if(response.ok) location.reload(); });
document.getElementById('open-settings').addEventListener('click',()=>document.getElementById('settings-dialog').showModal());
document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').close());
document.addEventListener('click',e=>{ document.querySelectorAll('details.menu[open]').forEach(menu=>{ if(!menu.contains(e.target)) menu.removeAttribute('open'); }); });
document.getElementById('settings-dialog').addEventListener('click',e=>{ if(e.target===e.currentTarget) e.currentTarget.close(); });
document.getElementById('settings-form').addEventListener('submit', async e=>{ e.preventDefault(); const body=Object.fromEntries(new FormData(e.target)); const response=await postJson('/api/settings',body); if(response.ok){ const data=await response.json(); document.getElementById('sab-token-status').textContent=data.sab_api_key_configured?'configured':'not configured'; ['sab_api_key','radarr_api_key','sonarr_api_key'].forEach(name=>e.target.elements[name].value=''); document.getElementById('settings-dialog').close(); } });
document.querySelectorAll('[data-test-connection]').forEach(button=>button.addEventListener('click', async()=>{ const form=document.getElementById('settings-form'); const service=button.dataset.testConnection; const prefix=service==='sabnzbd'?'sab':service; const output=document.getElementById(`${service}-test-result`); output.textContent='Testing…'; output.className=''; const response=await postJson('/api/settings/test-connection',{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}); if(response.ok){ const data=await response.json(); output.textContent=data.message; output.className=data.ok?'success':'error'; } }));
document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); });
refresh(); setInterval(refresh, 10000);
</script>
</body>
-1
View File
@@ -22,7 +22,6 @@ test = ["pytest>=8.2", "pytest-asyncio>=0.23"]
[project.scripts]
importarr = "importarr.main:run"
importarr-status = "importarr.status_ui:main"
manual-media-import = "importarr.worker:main"
[tool.pytest.ini_options]
+148
View File
@@ -42,6 +42,59 @@ def test_start_reenables_manual_queue_sync(tmp_path, monkeypatch):
assert len(main.state.list_queue_items()) == 1
def test_queue_jobs_include_groups_and_manual_context(tmp_path, monkeypatch):
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
batch = download / "Release" / "Season 1"
batch.mkdir(parents=True)
(batch / "Episode.mkv").write_bytes(b"episode")
main.state.add_manual_batch(batch.parent)
main.sync_manual_queue()
jobs = main.queue_jobs()
assert jobs[0]["group"] == "manual_batch"
assert jobs[0]["relative_path"] == "Release/Season 1/Episode.mkv"
assert jobs[0]["can_run_now"] is True
def test_queue_item_retry_ignore_and_remove_actions(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
row = main.state.upsert_queue_item(source_type="sab", source_id="job-1", name="Release", state="failed", reason="ImportError")
retried = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="retry"))
assert retried["item"]["state"] == "ready"
assert retried["item"]["reason"] == "retry requested"
ignored = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="ignore"))
assert ignored["item"]["state"] == "skipped"
removed = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="remove"))
assert removed == {"status": "removed", "id": row["id"]}
assert main.state.get_queue_item(row["id"]) is None
def test_queue_item_run_now_imports_only_selected_item(tmp_path, monkeypatch):
main, download, movies, _tv = configure_main(tmp_path, monkeypatch)
batch = download / "Release"
batch.mkdir(parents=True)
selected = batch / "Selected.mkv"
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")
result = main.queue_item_action(selected_row["id"], main.QueueItemActionRequest(action="run-now"))
assert result["status"] == "imported"
assert result["imported"] == 1
assert (movies / "Selected.mkv").read_bytes() == b"selected"
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"
def test_cancel_current_stops_before_next_manual_item(tmp_path, monkeypatch):
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
batch = download / "Release"
@@ -78,3 +131,98 @@ def test_cancel_current_stops_active_copy(tmp_path, monkeypatch):
assert source.exists()
assert not any(movies.glob("*.partial"))
assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped"
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"]
monkeypatch.setattr(main, "check_update_available", lambda: {"status": "update_available", "current_version": "0.1.0", "latest_version": "0.2.0", "update_available": True, "release_url": None})
calls = []
def fake_run(command, **kwargs):
calls.append((command, kwargs))
return main.subprocess.CompletedProcess(command, 0, stdout="updated", stderr="")
monkeypatch.setattr(main.subprocess, "run", fake_run)
result = main.update_service()
assert result["status"] == "update_available"
assert result["command_result"]["status"] == "ok"
assert result["command_result"]["command"] == ["upgrade", "now"]
assert result["command_result"]["stdout"] == "updated"
assert calls[0][0] == ["upgrade", "now"]
assert calls[0][1].get("shell") is not True
def test_control_update_skips_command_when_current(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
main.settings.update_command = ["upgrade", "now"]
monkeypatch.setattr(main, "check_update_available", lambda: {"status": "current", "current_version": "0.2.0", "latest_version": "v0.2.0", "update_available": False, "release_url": None})
def fake_run(command, **kwargs):
raise AssertionError("update command should not run without a newer release")
monkeypatch.setattr(main.subprocess, "run", fake_run)
result = main.update_service()
assert result["status"] == "current"
assert result["command"] == ["upgrade", "now"]
assert result["update_available"] is False
def test_update_check_compares_latest_release(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
monkeypatch.setenv("IMPORTARR_VERSION", "0.1.0")
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"tag_name": "v0.2.0", "html_url": "https://example.test/releases/v0.2.0"}
class FakeClient:
def __init__(self, timeout):
self.timeout = timeout
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def get(self, url, headers):
assert url == main.settings.update_release_url
assert headers["Accept"] == "application/json"
return FakeResponse()
monkeypatch.setattr(main.httpx, "Client", FakeClient)
result = main.check_update_available()
assert result["status"] == "update_available"
assert result["current_version"] == "0.1.0"
assert result["latest_version"] == "v0.2.0"
assert result["update_available"] is True
def test_control_restart_reports_command_failure(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
main.settings.restart_command = ["restart"]
def fake_run(command, **kwargs):
return main.subprocess.CompletedProcess(command, 1, stdout="", stderr="failed")
monkeypatch.setattr(main.subprocess, "run", fake_run)
try:
main.restart_service()
except main.HTTPException as exc:
assert exc.status_code == 500
assert exc.detail["status"] == "failed"
assert exc.detail["stderr"] == "failed"
else:
raise AssertionError("expected HTTPException")
+35
View File
@@ -19,9 +19,44 @@ def test_completed_manual_is_ready():
def test_wrong_category_ignored():
result = classify_history_item(item(category="*"), set(), "manual", ROOT)
assert result.ready
assert result.reason == "SAB completed inside Importarr download root"
def test_wrong_category_outside_root_ignored():
result = classify_history_item(item(category="*", storage="/tmp/other/Movie"), set(), "manual", ROOT)
assert result.state == "ignored"
def test_sab_storage_root_maps_to_local_download_root():
result = classify_history_item(
item(category="*", storage="/data/downloads/manual/Movie"),
set(),
"manual",
ROOT,
sab_storage_root=Path("/data/downloads/manual"),
)
assert result.ready
assert result.storage == ROOT / "Movie"
def test_radarr_sonarr_storage_roots_are_not_importarr_owned():
for storage in ("/data/downloads/movies/Movie", "/data/downloads/tv/Show"):
result = classify_history_item(
item(category="*", storage=storage),
set(),
"manual",
ROOT,
sab_storage_root=Path("/data/downloads/manual"),
)
assert result.state == "ignored"
def test_sab_cat_field_is_treated_as_category():
result = classify_history_item(item(category=None, cat="manual"), set(), "manual", ROOT)
assert result.ready
def test_queue_item_not_ready():
result = classify_history_item(item(), {"1"}, "manual", ROOT)
assert result.state == "processing"
+13
View File
@@ -0,0 +1,13 @@
from importarr.state import State
def test_delete_queue_items_by_state_can_target_reason(tmp_path):
state = State(tmp_path / "state.db")
state.upsert_queue_item(source_type="sab", source_id="stale", name="Stale", state="ignored", reason="SAB category is not owned by Importarr")
state.upsert_queue_item(source_type="sab", source_id="other", name="Other", state="ignored", reason="other reason")
assert state.delete_queue_items_by_state("sab", "ignored", "SAB category is not owned by Importarr") == 1
rows = state.list_queue_items()
assert len(rows) == 1
assert rows[0]["source_id"] == "other"
+166
View File
@@ -8,6 +8,19 @@ def test_health_contains_build_info(tmp_path, monkeypatch):
assert payload["version"]
def test_build_date_is_rendered_in_local_time(monkeypatch):
import importarr.build_info as build_info
monkeypatch.setenv("TZ", "Europe/Copenhagen")
import time
time.tzset()
monkeypatch.setenv("IMPORTARR_BUILD_DATE", "2026-07-29T12:00:00Z")
assert build_info.build_info()["build_date"] == "2026-07-29T14:00:00+02:00"
def test_status_contains_service_configuration(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
@@ -18,3 +31,156 @@ def test_status_contains_service_configuration(tmp_path, monkeypatch):
assert "movies_root" in payload
assert "tv_root" in payload
assert "auth_enabled" in payload
def test_index_renders_queue_controls(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
response = TestClient(main.app).get("/")
assert response.status_code == 200
assert "Queue controls" in response.text
assert "cancel-current" in response.text
def test_index_packs_secondary_controls_into_menu(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
response = TestClient(main.app).get("/")
assert response.status_code == 200
assert 'class="menu"' in response.text
assert "Service info and build details" in response.text
assert "Queue and history" in response.text
assert "Current import" in response.text
assert "details.menu[open]" in response.text
assert "e.target===e.currentTarget" in response.text
assert "function readiness" in response.text
assert 'title="${esc(j.reason||j.state)}"' in response.text
def test_index_renders_settings_dialog(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
response = TestClient(main.app).get("/")
assert response.status_code == 200
assert "settings-dialog" in response.text
assert "SABnzbd" in response.text
assert "Radarr" in response.text
assert "Sonarr" in response.text
assert "Test SABnzbd connection" in response.text
assert "Test Radarr connection" in response.text
assert "Test Sonarr connection" in response.text
def test_index_renders_responsive_table_wrappers(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
response = TestClient(main.app).get("/")
assert response.status_code == 200
assert 'class="table-scroll"' in response.text
assert "job-cards" in response.text
assert "job-card" in response.text
assert '<meta name="viewport" content="width=device-width, initial-scale=1">' in response.text
def test_stylesheet_includes_mobile_responsive_rules():
from pathlib import Path
css = Path("importarr/static/importarr.css").read_text()
assert "@media (max-width:640px)" in css
assert ".table-scroll" in css
assert "overflow-x:auto" in css
assert "flex-direction:column" in css
assert ".jobs-table{display:none}" in css
assert ".job-cards{display:block}" in css
assert "word-break:break-word" in css
assert "max-width:1280px" in css
assert ".jobs-table table{table-layout:fixed;min-width:0}" in css
assert ".jobs-table th:nth-child(1){width:66%}" in css
assert ".row-actions button{width:1.85rem" in css
assert ".summary-strip" in css
assert ".menu-panel" in css
assert "@media (prefers-color-scheme:dark)" in css
assert "--primary:#4b42b8" in css
assert "--cyan:#50dce5" in css
def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
response = TestClient(main.app).post(
"/api/settings",
json={
"sab_url": "http://sab:8080",
"sab_api_key": "sab-secret",
"radarr_url": "http://radarr:7878",
"radarr_api_key": "radarr-secret",
"sonarr_url": "http://sonarr:8989",
"sonarr_api_key": "sonarr-secret",
},
)
assert response.status_code == 200
assert response.json() == {
"sab_url": "http://sab:8080",
"sab_api_key_configured": True,
"radarr_url": "http://radarr:7878",
"radarr_api_key_configured": True,
"sonarr_url": "http://sonarr:8989",
"sonarr_api_key_configured": True,
}
assert main.settings.sab_api_key == "sab-secret"
assert main.state.get_app_state("radarr_api_key") == "radarr-secret"
def test_sab_connection_test_reports_success(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
async def fake_queue(self):
return {"queue": {"slots": [{"name": "one"}, {"name": "two"}]}}
monkeypatch.setattr(main.SabnzbdClient, "queue", fake_queue)
response = TestClient(main.app).post(
"/api/settings/test-connection",
json={"service": "sabnzbd", "url": "http://sab:8080", "api_key": "secret"},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "service": "sabnzbd", "message": "Connected to SABnzbd; 2 queued jobs visible."}
def test_connection_test_rejects_unknown_service(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
response = TestClient(main.app).post(
"/api/settings/test-connection",
json={"service": "lidarr", "url": "http://lidarr:8686"},
)
assert response.status_code == 400