Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3929fae4 | ||
|
|
b2c34ef995 | ||
|
|
da4df50205 | ||
|
|
2a7c39bf6a | ||
|
|
5752e9fb2f | ||
|
|
6cdf1f5d49 | ||
|
|
7c50def0c9 | ||
|
|
2156989b4b | ||
|
|
d15ea13cb3 | ||
|
|
5829623a9e | ||
|
|
c2ccb0d4bb | ||
|
|
f9eb633e19 | ||
|
|
ce82a405c5 | ||
|
|
22a1fc5522 | ||
|
|
581934f7b5 | ||
|
|
2ff670a9ae | ||
|
|
57c266dfa8 | ||
|
|
a443909d64 | ||
|
|
f4d151f9fe | ||
|
|
1cafe2b45a |
@@ -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`.
|
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.
|
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
|
### Required setup
|
||||||
@@ -71,6 +73,14 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
|
|||||||
- `GET /api/manual-batches`
|
- `GET /api/manual-batches`
|
||||||
- `POST /api/manual-batches` with `{ "path": "relative/or/absolute/path" }`
|
- `POST /api/manual-batches` with `{ "path": "relative/or/absolute/path" }`
|
||||||
- `DELETE /api/manual-batches/{id}`
|
- `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`
|
- `POST /api/import/run-now`
|
||||||
|
|
||||||
Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` to require `Authorization: Bearer <token>` for write endpoints.
|
Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` to require `Authorization: Bearer <token>` for write endpoints.
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ IMPORTARR_SAB_URL=http://sabnzbd:8080
|
|||||||
# IMPORTARR_SAB_API_KEY=change-me
|
# IMPORTARR_SAB_API_KEY=change-me
|
||||||
# IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key
|
# IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key
|
||||||
IMPORTARR_SAB_CATEGORY=manual
|
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_DOWNLOAD_ROOT=/data/downloads/manual
|
||||||
IMPORTARR_MOVIES_ROOT=/data/movies
|
IMPORTARR_MOVIES_ROOT=/data/movies
|
||||||
IMPORTARR_TV_ROOT=/data/tv
|
IMPORTARR_TV_ROOT=/data/tv
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Importarr manual media importer status UI
|
Description=Importarr manual media importer web UI
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
EnvironmentFile=-/etc/importarr/importarr.env
|
EnvironmentFile=-/etc/importarr/importarr.env
|
||||||
EnvironmentFile=-/opt/importarr/build.env
|
EnvironmentFile=-/opt/importarr/build.env
|
||||||
ExecStart=/opt/importarr/venv/bin/importarr-status
|
ExecStart=/opt/importarr/venv/bin/importarr
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
User=root
|
User=root
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ fi
|
|||||||
git fetch --prune origin
|
git fetch --prune origin
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
"$VENV/bin/pip" install --upgrade "$REPO_DIR"
|
"$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)"
|
GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || printf development)"
|
||||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
cat > "$PREFIX/build.env" <<EOF
|
cat > "$PREFIX/build.env" <<EOF
|
||||||
|
|||||||
+19
-2
@@ -8,14 +8,31 @@ from . import __version__
|
|||||||
|
|
||||||
|
|
||||||
def build_info() -> dict[str, str]:
|
def build_info() -> dict[str, str]:
|
||||||
|
build_date = os.getenv("IMPORTARR_BUILD_DATE", "development")
|
||||||
return {
|
return {
|
||||||
"name": "Importarr",
|
"name": "Importarr",
|
||||||
"version": os.getenv("IMPORTARR_VERSION", __version__),
|
"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"),
|
"git_sha": os.getenv("IMPORTARR_GIT_SHA", "development"),
|
||||||
"python": platform.python_version(),
|
"python": platform.python_version(),
|
||||||
"started_at": STARTED_AT,
|
"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")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -10,6 +11,7 @@ class Settings(BaseModel):
|
|||||||
sab_url: str = "http://sabnzbd:8080"
|
sab_url: str = "http://sabnzbd:8080"
|
||||||
sab_api_key: str | None = None
|
sab_api_key: str | None = None
|
||||||
sab_category: str = "manual"
|
sab_category: str = "manual"
|
||||||
|
sab_storage_root: Path | None = None
|
||||||
download_root: Path = Path("/data/downloads/manual")
|
download_root: Path = Path("/data/downloads/manual")
|
||||||
movies_root: Path = Path("/data/movies")
|
movies_root: Path = Path("/data/movies")
|
||||||
tv_root: Path = Path("/data/tv")
|
tv_root: Path = Path("/data/tv")
|
||||||
@@ -20,6 +22,11 @@ class Settings(BaseModel):
|
|||||||
sonarr_url: str | None = None
|
sonarr_url: str | None = None
|
||||||
sonarr_api_key: str | None = None
|
sonarr_api_key: str | None = None
|
||||||
auth_token: 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_host: str = "127.0.0.1"
|
||||||
bind_port: int = 8765
|
bind_port: int = 8765
|
||||||
poll_seconds: int = Field(default=60, ge=5)
|
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_url=os.getenv("IMPORTARR_SAB_URL", cls.model_fields["sab_url"].default),
|
||||||
sab_api_key=_env_secret("IMPORTARR_SAB_API_KEY"),
|
sab_api_key=_env_secret("IMPORTARR_SAB_API_KEY"),
|
||||||
sab_category=os.getenv("IMPORTARR_SAB_CATEGORY", "manual"),
|
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")),
|
download_root=Path(os.getenv("IMPORTARR_DOWNLOAD_ROOT", "/data/downloads/manual")),
|
||||||
movies_root=Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies")),
|
movies_root=Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies")),
|
||||||
tv_root=Path(os.getenv("IMPORTARR_TV_ROOT", "/data/tv")),
|
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_url=os.getenv("IMPORTARR_SONARR_URL"),
|
||||||
sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"),
|
sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"),
|
||||||
auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"),
|
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_host=os.getenv("IMPORTARR_BIND_HOST", "127.0.0.1"),
|
||||||
bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")),
|
bind_port=int(os.getenv("IMPORTARR_BIND_PORT", "8765")),
|
||||||
poll_seconds=int(os.getenv("IMPORTARR_POLL_SECONDS", "60")),
|
poll_seconds=int(os.getenv("IMPORTARR_POLL_SECONDS", "60")),
|
||||||
@@ -61,3 +74,10 @@ def _env_secret(name: str) -> str | None:
|
|||||||
if file_value:
|
if file_value:
|
||||||
return Path(file_value).read_text(encoding="utf-8").strip()
|
return Path(file_value).read_text(encoding="utf-8").strip()
|
||||||
return os.getenv(name)
|
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
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import httpx
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -37,6 +39,51 @@ class QueueControlRequest(BaseModel):
|
|||||||
mode: str
|
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:
|
def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
||||||
if not settings.auth_token:
|
if not settings.auth_token:
|
||||||
return
|
return
|
||||||
@@ -51,7 +98,7 @@ def health() -> dict[str, str]:
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request) -> 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")
|
@app.get("/api/status")
|
||||||
@@ -66,6 +113,11 @@ def status() -> dict[str, object]:
|
|||||||
"movies_root": str(settings.movies_root),
|
"movies_root": str(settings.movies_root),
|
||||||
"tv_root": str(settings.tv_root),
|
"tv_root": str(settings.tv_root),
|
||||||
"sab_url": settings.sab_url,
|
"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),
|
"auth_enabled": bool(settings.auth_token),
|
||||||
"bind": f"{settings.bind_host}:{settings.bind_port}",
|
"bind": f"{settings.bind_host}:{settings.bind_port}",
|
||||||
"manual_batches": len(state.list_manual_batches(active_only=True)),
|
"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]:
|
def control_status() -> dict[str, object]:
|
||||||
mode = state.get_app_state("queue_mode", "running") or "running"
|
mode = state.get_app_state("queue_mode", "running") or "running"
|
||||||
current = state.get_app_state("current_job")
|
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()
|
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")
|
@app.get("/api/manual-batches")
|
||||||
def manual_batches() -> list[dict[str, object]]:
|
def manual_batches() -> list[dict[str, object]]:
|
||||||
if queue_accepting_new_jobs():
|
if queue_accepting_new_jobs():
|
||||||
@@ -173,6 +370,29 @@ def history() -> list[dict[str, object]]:
|
|||||||
return state.list_history()
|
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")
|
@app.get("/api/jobs")
|
||||||
async def jobs() -> dict[str, object]:
|
async def jobs() -> dict[str, object]:
|
||||||
return await preview()
|
return await preview()
|
||||||
@@ -183,7 +403,7 @@ async def preview() -> dict[str, object]:
|
|||||||
if queue_accepting_new_jobs():
|
if queue_accepting_new_jobs():
|
||||||
await sync_queue()
|
await sync_queue()
|
||||||
jobs = queue_jobs()
|
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:
|
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__)
|
state.upsert_queue_item(source_type="system", source_id="sab-sync", name="SABnzbd", state="failed", reason=exc.__class__.__name__)
|
||||||
return
|
return
|
||||||
slots = data.get("history", {}).get("slots", [])
|
slots = data.get("history", {}).get("slots", [])
|
||||||
|
state.delete_queue_items_by_state("sab", "ignored")
|
||||||
for item in slots:
|
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 "")
|
job_id = str(item.get("nzo_id") or item.get("nzoid") or item.get("name") or "")
|
||||||
if not job_id:
|
if not job_id:
|
||||||
continue
|
continue
|
||||||
if readiness.ready and readiness.storage:
|
if readiness.ready and readiness.storage:
|
||||||
for video in scan_videos(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:
|
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]]:
|
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"],
|
"name": item["name"],
|
||||||
"state": item["state"],
|
"state": state_name,
|
||||||
|
"group": job_group(state_name, source_type),
|
||||||
"reason": item["reason"],
|
"reason": item["reason"],
|
||||||
"relative_path": item["relative_path"],
|
"relative_path": item["relative_path"],
|
||||||
"storage": item["source_path"],
|
"storage": item["source_path"],
|
||||||
"size": item["size"],
|
"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]]:
|
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", []):
|
for item in data.get("history", {}).get("slots", []):
|
||||||
if consume_cancel_request():
|
if consume_cancel_request():
|
||||||
break
|
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):
|
if readiness.storage is None or (not readiness.ready and not force):
|
||||||
continue
|
continue
|
||||||
for video in scan_videos(readiness.storage):
|
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
|
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:
|
def _import_manual_batches(importer: Importer) -> int:
|
||||||
if queue_accepting_new_jobs():
|
if queue_accepting_new_jobs():
|
||||||
sync_manual_queue()
|
sync_manual_queue()
|
||||||
|
|||||||
+21
-9
@@ -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)
|
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 "")
|
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:
|
if not force_status and nzo_id and nzo_id in active_nzo_ids:
|
||||||
return Readiness("processing", "SAB job is still present in queue")
|
return Readiness("processing", "SAB job is still present in queue")
|
||||||
if str(item.get("category") or "") != category:
|
item_category = str(item.get("category") or item.get("cat") or "")
|
||||||
return Readiness("ignored", "SAB category is not owned by Importarr")
|
|
||||||
status = str(item.get("status") 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":
|
if not force_status and status == "Failed":
|
||||||
return Readiness("failed", "SAB history reports failure")
|
return Readiness("failed", "SAB history reports failure")
|
||||||
if not force_status and (status in NOT_READY_STATUSES or status != "Completed"):
|
if not force_status and (status in NOT_READY_STATUSES or status != "Completed"):
|
||||||
return Readiness("processing", f"SAB status is {status or 'unknown'}")
|
return Readiness("processing", f"SAB status is {status or 'unknown'}")
|
||||||
storage_value = str(item.get("storage") or "")
|
if storage is None:
|
||||||
if not storage_value:
|
|
||||||
return Readiness("unknown", "SAB completed item has no final storage")
|
return Readiness("unknown", "SAB completed item has no final storage")
|
||||||
storage = Path(storage_value).resolve()
|
if not storage_in_root:
|
||||||
root = download_root.resolve()
|
|
||||||
if storage != root and root not in storage.parents:
|
|
||||||
return Readiness("ignored", "SAB storage is outside configured download root", storage)
|
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):
|
if has_transient_part(storage):
|
||||||
return Readiness("processing", "SAB storage path contains transient unpack/admin marker", 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)
|
return Readiness("ready", reason, storage)
|
||||||
|
|||||||
+26
-3
@@ -46,6 +46,7 @@ class State:
|
|||||||
size integer not null default 0,
|
size integer not null default 0,
|
||||||
batch_id integer,
|
batch_id integer,
|
||||||
job_id text,
|
job_id text,
|
||||||
|
sab_category text,
|
||||||
first_seen_at text not null default current_timestamp,
|
first_seen_at text not null default current_timestamp,
|
||||||
updated_at text not null default current_timestamp,
|
updated_at text not null default current_timestamp,
|
||||||
completed_at text,
|
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()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_app_state(self, key: str, default: str | None = None) -> str | None:
|
def get_app_state(self, key: str, default: str | None = None) -> str | None:
|
||||||
@@ -111,11 +115,12 @@ class State:
|
|||||||
size: int = 0,
|
size: int = 0,
|
||||||
batch_id: int | None = None,
|
batch_id: int | None = None,
|
||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
|
sab_category: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""
|
"""
|
||||||
insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id)
|
insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id, sab_category)
|
||||||
values (?,?,?,?,?,?,?,?,?,?)
|
values (?,?,?,?,?,?,?,?,?,?,?)
|
||||||
on conflict(source_type, source_id) do update set
|
on conflict(source_type, source_id) do update set
|
||||||
source_path=excluded.source_path,
|
source_path=excluded.source_path,
|
||||||
name=excluded.name,
|
name=excluded.name,
|
||||||
@@ -125,10 +130,11 @@ class State:
|
|||||||
size=excluded.size,
|
size=excluded.size,
|
||||||
batch_id=excluded.batch_id,
|
batch_id=excluded.batch_id,
|
||||||
job_id=excluded.job_id,
|
job_id=excluded.job_id,
|
||||||
|
sab_category=excluded.sab_category,
|
||||||
updated_at=current_timestamp,
|
updated_at=current_timestamp,
|
||||||
completed_at=case when excluded.state in ('imported','failed','skipped') then current_timestamp else null end
|
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()
|
self.conn.commit()
|
||||||
row = self.conn.execute("select * from import_queue_items where source_type = ? and source_id = ?", (source_type, source_id)).fetchone()
|
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()
|
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:
|
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()
|
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:
|
for row in rows:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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()
|
|
||||||
@@ -8,18 +8,33 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div><h1>Importarr</h1><p>Manual SABnzbd imports, safely gated by SAB completion.</p></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="build"><strong>{{ status.build.version }}</strong><span>{{ status.build.git_sha[:12] }} · {{ status.build.build_date }}</span></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>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section class="cards">
|
<section class="summary-strip" aria-label="Importarr summary">
|
||||||
<article><strong>{{ status.imported_total }}</strong><span>Imported total</span></article>
|
<article><strong>{{ status.control.queue_mode }}</strong><span>Queue mode</span></article>
|
||||||
<article><strong>{{ status.failed_total }}</strong><span>Failed total</span></article>
|
<article><strong>{{ status.current or 'Idle' }}</strong><span>Current import</span></article>
|
||||||
<article><strong>{{ status.manual_batches }}</strong><span>Manual batches</span></article>
|
<article><strong>{{ status.imported_total }}</strong><span>Imported</span></article>
|
||||||
<article><strong>{{ status.category }}</strong><span>SAB category</span></article>
|
<article><strong>{{ status.failed_total }}</strong><span>Failed</span></article>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel">
|
<details class="panel packed" id="service-info">
|
||||||
<h2>Service info</h2>
|
<summary>Service info and build details</summary>
|
||||||
<dl class="info">
|
<dl class="info">
|
||||||
<dt>Name</dt><dd>{{ status.build.name }}</dd>
|
<dt>Name</dt><dd>{{ status.build.name }}</dd>
|
||||||
<dt>Version</dt><dd>{{ status.build.version }}</dd>
|
<dt>Version</dt><dd>{{ status.build.version }}</dd>
|
||||||
@@ -28,6 +43,9 @@
|
|||||||
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
|
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
|
||||||
<dt>Python</dt><dd>{{ status.build.python }}</dd>
|
<dt>Python</dt><dd>{{ status.build.python }}</dd>
|
||||||
<dt>SAB URL</dt><dd>{{ status.sab_url }}</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>Download root</dt><dd>{{ status.download_root }}</dd>
|
||||||
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
|
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
|
||||||
<dt>TV root</dt><dd>{{ status.tv_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>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>
|
<dt>Current job</dt><dd id="current-job">{{ status.current or 'idle' }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</details>
|
||||||
<section class="panel">
|
<details class="panel packed">
|
||||||
<h2>Queue controls</h2>
|
<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>
|
<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">
|
<div class="controls">
|
||||||
<button type="button" data-control="start">Start</button>
|
<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="stop">Stop</button>
|
||||||
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
|
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</details>
|
||||||
<section>
|
<details class="panel packed" id="manual-batches">
|
||||||
<h2>Manual batches</h2>
|
<summary>Manual batches</summary>
|
||||||
<form id="batch-form" class="inline-form">
|
<form id="batch-form" class="inline-form">
|
||||||
<input name="path" placeholder="folder under download root">
|
<input name="path" placeholder="folder under download root">
|
||||||
<input id="batch-picker" type="file" webkitdirectory directory multiple hidden>
|
<input id="batch-picker" type="file" webkitdirectory directory multiple hidden>
|
||||||
<button type="button" id="browse-batch">Browse…</button>
|
<button type="button" id="browse-batch">Browse…</button>
|
||||||
<button>Add batch</button>
|
<button>Add batch</button>
|
||||||
</form>
|
</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 %}
|
{% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %}
|
||||||
</tbody></table>
|
</tbody></table></div>
|
||||||
</section>
|
</details>
|
||||||
<section>
|
<section class="panel queue-panel">
|
||||||
<h2>Jobs</h2><button id="force-run" type="button">Force run now</button><div id="jobs">Loading…</div>
|
<div class="section-title"><h2>Queue and history</h2><span>Grouped by processing state</span></div>
|
||||||
|
<div id="jobs">Loading…</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
<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'; } }
|
const esc=value=>String(value??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||||||
document.querySelectorAll('[data-control]').forEach(button=>button.addEventListener('click', async()=>{ await fetch(`/api/control/${button.dataset.control}`,{method:'POST'}); await refresh(); }));
|
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('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-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('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('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('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);
|
refresh(); setInterval(refresh, 10000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ test = ["pytest>=8.2", "pytest-asyncio>=0.23"]
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
importarr = "importarr.main:run"
|
importarr = "importarr.main:run"
|
||||||
importarr-status = "importarr.status_ui:main"
|
|
||||||
manual-media-import = "importarr.worker:main"
|
manual-media-import = "importarr.worker:main"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
@@ -42,6 +42,59 @@ def test_start_reenables_manual_queue_sync(tmp_path, monkeypatch):
|
|||||||
assert len(main.state.list_queue_items()) == 1
|
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):
|
def test_cancel_current_stops_before_next_manual_item(tmp_path, monkeypatch):
|
||||||
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
|
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
|
||||||
batch = download / "Release"
|
batch = download / "Release"
|
||||||
@@ -78,3 +131,98 @@ def test_cancel_current_stops_active_copy(tmp_path, monkeypatch):
|
|||||||
assert source.exists()
|
assert source.exists()
|
||||||
assert not any(movies.glob("*.partial"))
|
assert not any(movies.glob("*.partial"))
|
||||||
assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped"
|
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")
|
||||||
|
|||||||
@@ -19,9 +19,44 @@ def test_completed_manual_is_ready():
|
|||||||
|
|
||||||
def test_wrong_category_ignored():
|
def test_wrong_category_ignored():
|
||||||
result = classify_history_item(item(category="*"), set(), "manual", ROOT)
|
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"
|
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():
|
def test_queue_item_not_ready():
|
||||||
result = classify_history_item(item(), {"1"}, "manual", ROOT)
|
result = classify_history_item(item(), {"1"}, "manual", ROOT)
|
||||||
assert result.state == "processing"
|
assert result.state == "processing"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -8,6 +8,19 @@ def test_health_contains_build_info(tmp_path, monkeypatch):
|
|||||||
assert payload["version"]
|
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):
|
def test_status_contains_service_configuration(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
import importarr.main as main
|
import importarr.main as main
|
||||||
@@ -18,3 +31,156 @@ def test_status_contains_service_configuration(tmp_path, monkeypatch):
|
|||||||
assert "movies_root" in payload
|
assert "movies_root" in payload
|
||||||
assert "tv_root" in payload
|
assert "tv_root" in payload
|
||||||
assert "auth_enabled" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user