Install Importarr from repository checkout
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
PYTHON ?= .venv/bin/python
|
||||||
|
PIP ?= .venv/bin/pip
|
||||||
|
SERVICE ?= importarr.service
|
||||||
|
LIVE_URL ?= http://127.0.0.1:8095
|
||||||
|
|
||||||
|
.PHONY: test install-systemd install-editable upgrade-local repo-upgrade verify-live
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(PYTHON) -m pytest
|
||||||
|
|
||||||
|
install-systemd:
|
||||||
|
sudo -n sh deploy/systemd-install.sh
|
||||||
|
|
||||||
|
install-editable:
|
||||||
|
sudo -n /opt/importarr/venv/bin/pip install --upgrade --editable /srv/opencode-workspace/importarr
|
||||||
|
|
||||||
|
upgrade-local:
|
||||||
|
sudo -n /opt/importarr/venv/bin/pip install --upgrade --editable /srv/opencode-workspace/importarr
|
||||||
|
sudo -n systemctl restart $(SERVICE)
|
||||||
|
|
||||||
|
repo-upgrade:
|
||||||
|
sudo -n sh /opt/importarr/repo-upgrade.sh
|
||||||
|
|
||||||
|
verify-live:
|
||||||
|
curl -fsS $(LIVE_URL)/health
|
||||||
|
curl -fsS $(LIVE_URL)/api/status
|
||||||
|
curl -fsS $(LIVE_URL)/api/preview
|
||||||
@@ -29,21 +29,41 @@ docker build -t importarr:local .
|
|||||||
git clone https://gitea.delphas.dk/daniels/importarr.git
|
git clone https://gitea.delphas.dk/daniels/importarr.git
|
||||||
cd importarr
|
cd importarr
|
||||||
sudo sh deploy/systemd-install.sh
|
sudo sh deploy/systemd-install.sh
|
||||||
sudo install -o importarr -g importarr -d /var/lib/importarr
|
|
||||||
sudo ${EDITOR:-vi} /etc/importarr/importarr.env
|
sudo ${EDITOR:-vi} /etc/importarr/importarr.env
|
||||||
sudo systemctl start importarr.service
|
sudo systemctl start importarr.service
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer creates the `importarr` system user when needed.
|
The installer creates the `importarr` system user when needed, installs a virtualenv at `/opt/importarr/venv`, and installs the package in editable mode from the checked-out repository. The repository is therefore the source of truth: pull or edit the repo, reinstall/restart from the repo, and the service runs that code.
|
||||||
|
|
||||||
|
For local upgrades from a checked-out repo on dgsserver1, use the repo workflow instead of editing live scripts:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /srv/opencode-workspace/importarr
|
||||||
|
.venv/bin/python -m pytest
|
||||||
|
git status --short --branch
|
||||||
|
sudo -n /opt/importarr/venv/bin/pip install --upgrade --editable /srv/opencode-workspace/importarr
|
||||||
|
sudo -n systemctl restart importarr.service
|
||||||
|
make verify-live
|
||||||
|
```
|
||||||
|
|
||||||
|
For a machine that should stay current with the repository, use the installed repo-upgrade helper:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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`, refreshes the editable install, restarts `importarr.service`, and prints service status. Use it after changes have been committed and pushed to `main`.
|
||||||
|
|
||||||
|
Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.0`, ...), pushed with tags, then reinstalled from the tagged checkout or artifact. Do not hand-edit `/usr/local/sbin/importarr-status.py` or `/usr/local/sbin/manual-media-import.py` except for a documented emergency hotfix that is immediately backported here.
|
||||||
|
|
||||||
### Required setup
|
### Required setup
|
||||||
|
|
||||||
1. In SABnzbd, create or confirm category `manual`.
|
1. In SABnzbd, create or confirm category `manual`.
|
||||||
2. Set its completed folder to the same path mounted as `IMPORTARR_DOWNLOAD_ROOT`.
|
2. Set its completed folder to the same path mounted as `IMPORTARR_DOWNLOAD_ROOT`.
|
||||||
3. Set `IMPORTARR_SAB_URL` and `IMPORTARR_SAB_API_KEY`.
|
3. Set `IMPORTARR_SAB_URL` and `IMPORTARR_SAB_API_KEY_FILE` or `IMPORTARR_SAB_API_KEY`.
|
||||||
4. Mount/configure `IMPORTARR_MOVIES_ROOT` and `IMPORTARR_TV_ROOT` read/write.
|
4. Mount/configure `IMPORTARR_MOVIES_ROOT` and `IMPORTARR_TV_ROOT` read/write.
|
||||||
5. Set `IMPORTARR_AUTH_TOKEN` unless write endpoints are protected by a reverse proxy.
|
5. Set `IMPORTARR_AUTH_TOKEN` unless write endpoints are protected by a reverse proxy.
|
||||||
6. Check `GET /health`, then inspect `/api/jobs` before running imports.
|
6. Check `GET /health`, then inspect `/api/preview` before running imports.
|
||||||
|
|
||||||
## Safety model
|
## Safety model
|
||||||
|
|
||||||
@@ -57,13 +77,14 @@ The installer creates the `importarr` system user when needed.
|
|||||||
- `GET /health`
|
- `GET /health`
|
||||||
- `GET /api/status`
|
- `GET /api/status`
|
||||||
- `GET /api/jobs`
|
- `GET /api/jobs`
|
||||||
|
- `GET /api/preview`
|
||||||
- `GET /api/history`
|
- `GET /api/history`
|
||||||
- `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/import/run-now`
|
- `POST /api/import/run-now`
|
||||||
|
|
||||||
Set `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.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -77,4 +98,18 @@ uvicorn importarr.main:app --reload
|
|||||||
|
|
||||||
## Migration notes for dgsserver1
|
## Migration notes for dgsserver1
|
||||||
|
|
||||||
Export the existing script settings into `IMPORTARR_*` env vars, add historical folders as explicit manual batches, run a dry-run/inspection through `/api/jobs`, then switch the systemd service or Compose route after the ready set matches expectations.
|
Export the existing script settings into `IMPORTARR_*` env vars, add historical folders as explicit manual batches, run a dry-run/inspection through `/api/preview`, then switch the systemd service or Compose route after the ready set matches expectations.
|
||||||
|
|
||||||
|
On dgsserver1, the packaged service is the only intended active entrypoint after cutover. Keep `manual-media-import.timer` disabled unless a repo-managed worker/timer replaces it later.
|
||||||
|
|
||||||
|
## Repository-as-install workflow
|
||||||
|
|
||||||
|
Importarr should not drift into host-local scripts. Treat the checked-out repository as the install source:
|
||||||
|
|
||||||
|
1. Make changes in `/srv/opencode-workspace/importarr`.
|
||||||
|
2. Run tests: `make test`.
|
||||||
|
3. Commit and push the repo change.
|
||||||
|
4. Install/restart from the same repo: `make upgrade-local` for local changes, or `make repo-upgrade` to pull the latest pushed `main` and restart.
|
||||||
|
5. Verify the live service: `make verify-live`.
|
||||||
|
|
||||||
|
Do not edit `/usr/local/sbin/importarr-status.py`, `/usr/local/sbin/manual-media-import.py`, or files copied out of the repo as the normal workflow. If an emergency live hotfix is unavoidable, backport it to this repository immediately and run the repo install workflow again.
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
IMPORTARR_SAB_URL=http://sabnzbd:8080
|
IMPORTARR_SAB_URL=http://127.0.0.1:8080
|
||||||
IMPORTARR_SAB_API_KEY=change-me
|
# Prefer *_FILE for secrets. Plain env vars still work for local/dev installs.
|
||||||
|
# IMPORTARR_SAB_API_KEY=change-me
|
||||||
|
# IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key
|
||||||
IMPORTARR_SAB_CATEGORY=manual
|
IMPORTARR_SAB_CATEGORY=manual
|
||||||
IMPORTARR_DOWNLOAD_ROOT=/data/downloads/manual
|
IMPORTARR_DOWNLOAD_ROOT=/srv/scrypted/sabnzbd-data/downloads/manual
|
||||||
IMPORTARR_MOVIES_ROOT=/data/movies
|
IMPORTARR_MOVIES_ROOT=/srv/media/movies
|
||||||
IMPORTARR_TV_ROOT=/data/tv
|
IMPORTARR_TV_ROOT=/srv/media/tv
|
||||||
IMPORTARR_STATE_PATH=/config/importarr.db
|
IMPORTARR_STATE_PATH=/var/lib/importarr/importarr.db
|
||||||
IMPORTARR_LOG_LEVEL=info
|
IMPORTARR_LOG_LEVEL=info
|
||||||
IMPORTARR_AUTH_TOKEN=change-me
|
# IMPORTARR_AUTH_TOKEN=change-me
|
||||||
|
# IMPORTARR_AUTH_TOKEN_FILE=/etc/importarr/auth-token
|
||||||
IMPORTARR_BIND_HOST=0.0.0.0
|
IMPORTARR_BIND_HOST=0.0.0.0
|
||||||
IMPORTARR_BIND_PORT=8765
|
IMPORTARR_BIND_PORT=8095
|
||||||
IMPORTARR_POLL_SECONDS=60
|
IMPORTARR_POLL_SECONDS=60
|
||||||
|
IMPORTARR_REPO_DIR=/srv/opencode-workspace/importarr
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Importarr manual media importer
|
Description=Importarr manual media importer
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
EnvironmentFile=/etc/importarr/importarr.env
|
EnvironmentFile=/etc/importarr/importarr.env
|
||||||
ExecStart=/usr/local/bin/importarr
|
ExecStart=/opt/importarr/venv/bin/importarr
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
User=importarr
|
User=importarr
|
||||||
Group=importarr
|
Group=importarr
|
||||||
StateDirectory=importarr
|
StateDirectory=importarr
|
||||||
ReadWritePaths=/var/lib/importarr /etc/importarr
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENV_FILE=${IMPORTARR_ENV_FILE:-/etc/importarr/importarr.env}
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_DIR=${IMPORTARR_REPO_DIR:-/srv/opencode-workspace/importarr}
|
||||||
|
SERVICE=${IMPORTARR_SERVICE:-importarr.service}
|
||||||
|
VENV=${IMPORTARR_VENV:-/opt/importarr/venv}
|
||||||
|
|
||||||
|
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||||
|
echo "Importarr repo not found at $REPO_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
echo "Refusing to upgrade: $REPO_DIR has uncommitted changes." >&2
|
||||||
|
git status --short >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --prune origin
|
||||||
|
git pull --ff-only
|
||||||
|
"$VENV/bin/pip" install --upgrade --editable "$REPO_DIR"
|
||||||
|
systemctl restart "$SERVICE"
|
||||||
|
systemctl --no-pager --full status "$SERVICE"
|
||||||
@@ -7,16 +7,24 @@ if [ "$(id -u)" -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
install -d -m 0755 /etc/importarr /var/lib/importarr
|
install -d -m 0755 /etc/importarr /var/lib/importarr
|
||||||
|
install -d -m 0755 /opt/importarr
|
||||||
|
REPO_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
if ! id importarr >/dev/null 2>&1; then
|
if ! id importarr >/dev/null 2>&1; then
|
||||||
useradd --system --home /var/lib/importarr --shell /usr/sbin/nologin importarr
|
useradd --system --home /var/lib/importarr --shell /usr/sbin/nologin importarr
|
||||||
fi
|
fi
|
||||||
chown importarr:importarr /var/lib/importarr
|
chown importarr:importarr /var/lib/importarr
|
||||||
|
python3 -m venv /opt/importarr/venv
|
||||||
|
/opt/importarr/venv/bin/pip install --upgrade pip
|
||||||
|
/opt/importarr/venv/bin/pip install --upgrade --editable "$REPO_DIR"
|
||||||
if [ ! -f /etc/importarr/importarr.env ]; then
|
if [ ! -f /etc/importarr/importarr.env ]; then
|
||||||
install -m 0600 deploy/importarr.env.example /etc/importarr/importarr.env
|
install -m 0600 "$REPO_DIR/deploy/importarr.env.example" /etc/importarr/importarr.env
|
||||||
echo "Created /etc/importarr/importarr.env; edit it before starting the service."
|
echo "Created /etc/importarr/importarr.env; edit it before starting the service."
|
||||||
fi
|
fi
|
||||||
install -m 0644 deploy/importarr.service /etc/systemd/system/importarr.service
|
if ! grep -q '^IMPORTARR_REPO_DIR=' /etc/importarr/importarr.env; then
|
||||||
python3 -m pip install --upgrade .
|
printf '\nIMPORTARR_REPO_DIR=%s\n' "$REPO_DIR" >> /etc/importarr/importarr.env
|
||||||
|
fi
|
||||||
|
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
|
||||||
|
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" /opt/importarr/repo-upgrade.sh
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable importarr.service
|
systemctl enable importarr.service
|
||||||
echo "Edit /etc/importarr/importarr.env, then run: systemctl start importarr.service"
|
echo "Edit /etc/importarr/importarr.env, then run: systemctl start importarr.service"
|
||||||
|
|||||||
+11
-4
@@ -28,7 +28,7 @@ class Settings(BaseModel):
|
|||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
return cls(
|
return cls(
|
||||||
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=os.getenv("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"),
|
||||||
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")),
|
||||||
@@ -36,10 +36,10 @@ class Settings(BaseModel):
|
|||||||
state_path=Path(os.getenv("IMPORTARR_STATE_PATH", "/config/importarr.db")),
|
state_path=Path(os.getenv("IMPORTARR_STATE_PATH", "/config/importarr.db")),
|
||||||
log_level=os.getenv("IMPORTARR_LOG_LEVEL", "info"),
|
log_level=os.getenv("IMPORTARR_LOG_LEVEL", "info"),
|
||||||
radarr_url=os.getenv("IMPORTARR_RADARR_URL"),
|
radarr_url=os.getenv("IMPORTARR_RADARR_URL"),
|
||||||
radarr_api_key=os.getenv("IMPORTARR_RADARR_API_KEY"),
|
radarr_api_key=_env_secret("IMPORTARR_RADARR_API_KEY"),
|
||||||
sonarr_url=os.getenv("IMPORTARR_SONARR_URL"),
|
sonarr_url=os.getenv("IMPORTARR_SONARR_URL"),
|
||||||
sonarr_api_key=os.getenv("IMPORTARR_SONARR_API_KEY"),
|
sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"),
|
||||||
auth_token=os.getenv("IMPORTARR_AUTH_TOKEN"),
|
auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"),
|
||||||
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")),
|
||||||
@@ -54,3 +54,10 @@ class Settings(BaseModel):
|
|||||||
if resolved != root and root not in resolved.parents:
|
if resolved != root and root not in resolved.parents:
|
||||||
raise ValueError("path must resolve under IMPORTARR_DOWNLOAD_ROOT")
|
raise ValueError("path must resolve under IMPORTARR_DOWNLOAD_ROOT")
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _env_secret(name: str) -> str | None:
|
||||||
|
file_value = os.getenv(f"{name}_FILE")
|
||||||
|
if file_value:
|
||||||
|
return Path(file_value).read_text(encoding="utf-8").strip()
|
||||||
|
return os.getenv(name)
|
||||||
|
|||||||
+14
-3
@@ -97,18 +97,29 @@ def history() -> list[dict[str, object]]:
|
|||||||
|
|
||||||
@app.get("/api/jobs")
|
@app.get("/api/jobs")
|
||||||
async def jobs() -> dict[str, object]:
|
async def jobs() -> dict[str, object]:
|
||||||
|
return await preview()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/preview")
|
||||||
|
async def preview() -> dict[str, object]:
|
||||||
client = SabnzbdClient(settings.sab_url, settings.sab_api_key)
|
client = SabnzbdClient(settings.sab_url, settings.sab_api_key)
|
||||||
try:
|
try:
|
||||||
active = await client.active_nzo_ids()
|
active = await client.active_nzo_ids()
|
||||||
data = await client.history()
|
data = await client.history()
|
||||||
except Exception as exc: # do not leak keys in URL/params
|
except Exception as exc: # do not leak keys in URL/params
|
||||||
return {"sab_status": "error", "error": exc.__class__.__name__, "jobs": manual_batch_jobs()}
|
jobs = manual_batch_jobs()
|
||||||
|
return {"sab_status": "error", "error": exc.__class__.__name__, "jobs": jobs, "would_import": len(jobs)}
|
||||||
slots = data.get("history", {}).get("slots", [])
|
slots = data.get("history", {}).get("slots", [])
|
||||||
rows = []
|
rows = []
|
||||||
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)
|
||||||
rows.append({"name": item.get("name"), "state": readiness.state, "reason": readiness.reason, "storage": str(readiness.storage) if readiness.storage else None})
|
if readiness.ready and readiness.storage:
|
||||||
return {"sab_status": "ok", "jobs": rows + manual_batch_jobs()}
|
for video in scan_videos(readiness.storage):
|
||||||
|
rows.append({"name": video.path.name, "state": "ready", "relative_path": str(video.relative_path), "storage": str(readiness.storage), "size": video.size})
|
||||||
|
else:
|
||||||
|
rows.append({"name": item.get("name"), "state": readiness.state, "reason": readiness.reason, "storage": str(readiness.storage) if readiness.storage else None})
|
||||||
|
jobs = rows + manual_batch_jobs()
|
||||||
|
return {"sab_status": "ok", "jobs": jobs, "would_import": sum(1 for row in jobs if row["state"] in {"ready", "manual_batch"})}
|
||||||
|
|
||||||
|
|
||||||
def manual_batch_jobs() -> list[dict[str, object]]:
|
def manual_batch_jobs() -> list[dict[str, object]]:
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from importarr.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_secret_env_vars_are_supported(tmp_path, monkeypatch):
|
||||||
|
sab_key = tmp_path / "sab-api-key"
|
||||||
|
auth_token = tmp_path / "auth-token"
|
||||||
|
sab_key.write_text("sab-secret\n", encoding="utf-8")
|
||||||
|
auth_token.write_text("auth-secret\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setenv("IMPORTARR_SAB_API_KEY_FILE", str(sab_key))
|
||||||
|
monkeypatch.setenv("IMPORTARR_AUTH_TOKEN_FILE", str(auth_token))
|
||||||
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
|
|
||||||
|
settings = Settings.from_env()
|
||||||
|
|
||||||
|
assert settings.sab_api_key == "sab-secret"
|
||||||
|
assert settings.auth_token == "auth-secret"
|
||||||
Reference in New Issue
Block a user