8 Commits
Author SHA1 Message Date
daniels d8cac38ed2 Restore header build info
Deploy live / test (push) Successful in 24s
Deploy live / deploy (push) Successful in 7s
Keep the deploy fixes while showing build SHA/date again in the\nheader, and regenerate the built frontend assets.
2026-08-11 14:39:37 +02:00
daniels 32984ab6ab Hide build info and fix deploy workflow
Deploy live / test (push) Successful in 23s
Deploy live / deploy (push) Successful in 7s
2026-08-11 11:50:36 +02:00
daniels a93480e48a Show build info in header
Deploy live / test (push) Failing after 36s
Deploy live / deploy (push) Skipped
Deploy the CI-built wheel for the triggering SHA so the UI and\nbuild metadata stay in sync in production.
2026-08-11 10:16:37 +02:00
daniels 52701569c3 Fix Actions deploy SSH quoting
Deploy live / test (push) Successful in 23s
Deploy live / deploy (push) Successful in 6s
2026-08-11 09:34:53 +02:00
daniels 1ed5964554 Retire review deploy and add live Actions
Deploy live / test (push) Successful in 23s
Deploy live / deploy (push) Failing after 1s
2026-08-11 08:03:49 +02:00
daniels c65b5c8406 Fix release asset deployment
Package built frontend assets in wheels and force clean upgrades.

Run API-triggered updates in a transient unit with a safe delay.
2026-08-09 09:11:28 +02:00
daniels 2d593f4b6c Fix queue UI responsiveness
Add browser auth retry for protected UI actions and update checks.

Speed queue handoff and release v0.1.2.
2026-08-09 07:37:04 +02:00
daniels e8b0645ea6 Harden live release workflow #25 2026-07-31 09:28:45 +02:00
25 changed files with 380 additions and 429 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Deploy live
on:
push:
branches:
- main
concurrency:
group: importarr-live
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: |
python -m pip install '.[test]'
npm --prefix frontend ci
- name: Test and build
run: |
python -m pytest -q
npm --prefix frontend test -- --run
npm --prefix frontend run build
sh -n deploy/systemd-install.sh
sh -n deploy/repo-upgrade.sh
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Configure deployment SSH
shell: bash
env:
DEPLOY_KEY: ${{ secrets.IMPORTARR_DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.IMPORTARR_DEPLOY_KNOWN_HOSTS }}
run: |
install -d -m 0700 ~/.ssh
install -m 0600 /dev/null ~/.ssh/id_ed25519
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Deploy origin/main
env:
DEPLOY_HOST: ${{ secrets.IMPORTARR_DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.IMPORTARR_DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.IMPORTARR_DEPLOY_USER }}
DEPLOY_SHA: ${{ github.sha }}
run: |
ssh -p "$DEPLOY_PORT" -o BatchMode=yes "$DEPLOY_USER@$DEPLOY_HOST" \
"sudo -n /bin/sh -c 'set -eu; cd /opt/importarr/repo;
test -z \"\$(git status --porcelain)\";
git fetch --prune origin main;
git checkout --detach $DEPLOY_SHA;
/bin/sh deploy/repo-upgrade.sh \"\" $DEPLOY_SHA'"
-2
View File
@@ -6,6 +6,4 @@ __pycache__/
*.db *.db
*.partial *.partial
AGENTS.local.md AGENTS.local.md
.review-data/
deploy/importarr.review.env
frontend/node_modules/ frontend/node_modules/
+4 -4
View File
@@ -1,7 +1,7 @@
# Repository Instructions # Repository Instructions
The Importarr review site at <http://172.20.30.35:18765/> must always reflect the local working-tree code. ## Deployment workflow
- After making local code changes, run `deploy/review/review.sh update` from the repository root. Pushes to `main` deploy through the bounded Gitea Actions workflow. Follow the
- Before finishing, verify that `http://172.20.30.35:18765/` responds successfully. host-local operational runbook for credentials, live URLs, and verification;
- If deployment or URL verification fails, report the failure clearly; do not claim the review site is current. never put tokens, SSH keys, or other secrets in this repository or logs.
+1 -1
View File
@@ -11,7 +11,7 @@ WORKDIR /app
COPY pyproject.toml README.md LICENSE ./ COPY pyproject.toml README.md LICENSE ./
COPY importarr ./importarr COPY importarr ./importarr
COPY --from=frontend /build/importarr/static ./importarr/static COPY --from=frontend /build/importarr/static ./importarr/static
ARG IMPORTARR_VERSION=0.1.0 ARG IMPORTARR_VERSION=0.1.1
ARG IMPORTARR_BUILD_DATE=unknown ARG IMPORTARR_BUILD_DATE=unknown
ARG IMPORTARR_GIT_SHA=unknown ARG IMPORTARR_GIT_SHA=unknown
ENV IMPORTARR_VERSION=$IMPORTARR_VERSION \ ENV IMPORTARR_VERSION=$IMPORTARR_VERSION \
+16 -42
View File
@@ -41,11 +41,23 @@ For a machine that should stay current with the repository, use the installed re
sudo -n sh /opt/importarr/repo-upgrade.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`, 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 requires a clean Git checkout at `/opt/importarr/repo` (override with `IMPORTARR_REPO_DIR` only for a nonstandard installation). It fetches and checks out exactly `origin/main`, reinstalls the package, records commit provenance, and restarts `importarr.service`.
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. The web UI Start, Stop, and Restart controls target `manual-media-import.service` by default; configure `IMPORTARR_START_COMMAND`, `IMPORTARR_STOP_COMMAND`, or `IMPORTARR_RESTART_COMMAND` when those defaults need a wrapper such as sudo. When bootstrapping an existing tagged-release installation, invoke the new helper directly from the checked-out repository once instead of invoking the old installed copy:
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. ```sh
sudo -n sh /opt/importarr/repo/deploy/repo-upgrade.sh
```
That first run installs the repository-based helper at `/opt/importarr/repo-upgrade.sh` and removes the obsolete `IMPORTARR_UPDATE_COMMAND` entry from the existing env file. Subsequent deployments can use the installed helper normally.
Pushes to `main` run `.gitea/workflows/deploy.yml`. Python tests, frontend tests/build, and deploy-script syntax checks must pass before the deploy job can start. The workflow then connects only to the configured live SSH host, refuses a dirty checkout, updates `/opt/importarr/repo` to `origin/main`, and invokes that checkout's upgrade helper through passwordless sudo.
Configure repository Actions secrets `IMPORTARR_DEPLOY_HOST`, `IMPORTARR_DEPLOY_PORT`, `IMPORTARR_DEPLOY_USER`, `IMPORTARR_DEPLOY_SSH_KEY` (a dedicated private key accepted for the deployment account), and `IMPORTARR_DEPLOY_KNOWN_HOSTS` (a pinned `known_hosts` line for the live host). The deployment account must allow the workflow's fixed `/bin/sh -c` deployment command through `sudo -n`. The live checkout must be able to fetch `origin/main`; do not put a Git credential in the workflow. Protect `main`: require pull requests and the workflow test job, restrict direct pushes and force pushes, and limit workflow/secret administration to trusted maintainers. Gitea Actions executes repository code before deployment, so deployment secrets must not be exposed to pull-request workflows and untrusted contributors must not be permitted to push to `main`. These controls are repository-administration prerequisites; this workflow cannot enforce them itself.
The upgrade helper holds an exclusive lock across fetch, checkout, install, provenance update, and service restart, so concurrent deployments cannot overlap. The web UI Start, Stop, and Restart controls target `manual-media-import.service` by default; configure `IMPORTARR_START_COMMAND`, `IMPORTARR_STOP_COMMAND`, or `IMPORTARR_RESTART_COMMAND` when those defaults need a wrapper such as sudo.
Release-worthy changes should be committed and pushed to `main`; tags remain optional release metadata and are not the deployment trigger.
### Required setup ### Required setup
@@ -53,7 +65,7 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
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_FILE` or `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_FILE` or `IMPORTARR_AUTH_TOKEN` for every non-loopback bind. Importarr fails closed on write/control endpoints when a non-loopback service has no token; tokenless local development remains available on `127.0.0.1`, `::1`, or `localhost`.
6. Check `GET /health`, then inspect `/api/preview` before running imports. 6. Check `GET /health`, then inspect `/api/preview` before running imports.
## Safety model ## Safety model
@@ -78,8 +90,6 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
- `POST /api/control/stop` - `POST /api/control/stop`
- `POST /api/control/cancel-current` - `POST /api/control/cancel-current`
- `POST /api/control/restart` - `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/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
- `POST /api/import/run-now` - `POST /api/import/run-now`
@@ -103,42 +113,6 @@ lucide-react for icons. Run `npm --prefix frontend run dev` for Vite's developme
server (it proxies API calls to port 8765), or build before running FastAPI so the server (it proxies API calls to port 8765), or build before running FastAPI so the
production assets are written to `importarr/static`. production assets are written to `importarr/static`.
### Persistent local review environment
The review Compose stack builds the current working tree, including uncommitted
UI/API changes, and stays running for pre-commit or pre-push inspection. It is
separate from production: the web port is bound to localhost by default, state and
sample media live under the ignored `.review-data/` directory, external Arr/SAB
services are not required, and service-control/update commands are safe no-ops.
```sh
deploy/review/review.sh up # build current files and start in background
deploy/review/review.sh update # rebuild changed files and recreate as needed
deploy/review/review.sh status
deploy/review/review.sh logs # follow logs; Ctrl-C leaves the stack running
deploy/review/review.sh stop # stop containers, preserving them and data
deploy/review/review.sh down # remove containers/network, preserving data
deploy/review/review.sh reset # remove stack and all local review data
```
On first use the wrapper copies `deploy/importarr.review.env.example` to the
ignored `deploy/importarr.review.env`. Adjust `REVIEW_PORT` there if port 18765
is occupied, then review `http://127.0.0.1:18765/`. For review from a trusted
internal network, set `REVIEW_BIND_ADDRESS` to the host's LAN address and use
that address in the URL. Do not use `0.0.0.0` or expose this review stack to an
untrusted network; the safe default is `127.0.0.1`. To exercise imports without
real integrations, place disposable folders in `.review-data/downloads/`; movie
and TV destinations are `.review-data/movies/` and `.review-data/tv/`.
Each checkout gets its own Compose project and image name, so worktrees do not
replace each other's containers or images. Read-only and cleanup commands do not
create the local env file when it is absent.
Agent workflow: run tests, run `update`, confirm `status` reports healthy, and
leave the stack running for the reviewer. Reviewer workflow: inspect the UI and
API, use `logs` when needed, and use `down` after review (or `reset` when the
saved review state is no longer useful). Re-run `update` after every working-tree
change that should be reviewed.
## Operations ## Operations
Importarr intentionally does not document private deployment topology, hostnames, reverse proxies, monitoring, backups, or operator workflows in this repository. Keep those details in your own ops runbooks. Importarr intentionally does not document private deployment topology, hostnames, reverse proxies, monitoring, backups, or operator workflows in this repository. Keep those details in your own ops runbooks.
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
importarr: importarr:
image: ghcr.io/OWNER/importarr:0.1.0 image: ghcr.io/OWNER/importarr:0.1.1
env_file: importarr.env env_file: importarr.env
ports: ports:
- "8765:8765" - "8765:8765"
-22
View File
@@ -1,22 +0,0 @@
services:
importarr:
build:
context: ..
dockerfile: Dockerfile
image: ${REVIEW_IMAGE:-importarr-review:local}
env_file:
- ${REVIEW_SERVICE_ENV_FILE:-importarr.review.env.example}
ports:
- "${REVIEW_BIND_ADDRESS:-127.0.0.1}:${REVIEW_PORT:-18765}:8765"
volumes:
- ../.review-data/config:/config
- ../.review-data/downloads:/data/downloads/manual
- ../.review-data/movies:/data/movies
- ../.review-data/tv:/data/tv
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=2)"]
interval: 10s
timeout: 3s
retries: 6
start_period: 5s
-1
View File
@@ -16,4 +16,3 @@ IMPORTARR_LOG_LEVEL=info
IMPORTARR_BIND_HOST=0.0.0.0 IMPORTARR_BIND_HOST=0.0.0.0
IMPORTARR_BIND_PORT=8765 IMPORTARR_BIND_PORT=8765
IMPORTARR_POLL_SECONDS=60 IMPORTARR_POLL_SECONDS=60
# IMPORTARR_REPO_DIR=/path/to/importarr
-23
View File
@@ -1,23 +0,0 @@
# Copied to importarr.review.env by deploy/review/review.sh. No secrets or
# external services are required for review.
REVIEW_PORT=18765
# Keep this on loopback unless review access from a trusted network is needed.
REVIEW_BIND_ADDRESS=127.0.0.1
IMPORTARR_BIND_HOST=0.0.0.0
IMPORTARR_BIND_PORT=8765
IMPORTARR_STATE_PATH=/config/importarr.db
IMPORTARR_DOWNLOAD_ROOT=/data/downloads/manual
IMPORTARR_MOVIES_ROOT=/data/movies
IMPORTARR_TV_ROOT=/data/tv
IMPORTARR_SAB_URL=http://127.0.0.1:9
IMPORTARR_SAB_CATEGORY=review
IMPORTARR_POLL_SECONDS=3600
IMPORTARR_LOG_LEVEL=info
# UI control operations must not control host services or update this checkout.
IMPORTARR_START_COMMAND=/bin/true
IMPORTARR_STOP_COMMAND=/bin/true
IMPORTARR_RESTART_COMMAND=/bin/true
IMPORTARR_UPDATE_COMMAND=/bin/true
IMPORTARR_UPDATE_RELEASE_URL=http://127.0.0.1:9/releases/latest
IMPORTARR_UPDATE_CHECK_TIMEOUT_SECONDS=1
+38 -5
View File
@@ -13,9 +13,26 @@ if [ -f "$ENV_FILE" ]; then
fi fi
PREFIX=${IMPORTARR_PREFIX:-/opt/importarr} PREFIX=${IMPORTARR_PREFIX:-/opt/importarr}
REPO_DIR=${IMPORTARR_REPO_DIR:-$(pwd)} REPO_DIR=${IMPORTARR_REPO_DIR:-$PREFIX/repo}
SERVICE=${IMPORTARR_SERVICE:-importarr.service} SERVICE=${IMPORTARR_SERVICE:-importarr.service}
VENV=${IMPORTARR_VENV:-$PREFIX/venv} VENV=${IMPORTARR_VENV:-$PREFIX/venv}
if [ "$#" -gt 2 ]; then
echo "Usage: $0 [wheel [revision]]" >&2
exit 2
fi
WHEEL=${1:-}
REVISION=${2:-}
if [ -n "$WHEEL" ] && [ ! -f "$WHEEL" ]; then
echo "Built wheel not found: $WHEEL" >&2
exit 1
fi
LOCK_FILE=${IMPORTARR_UPGRADE_LOCK_FILE:-$PREFIX/repo-upgrade.lock}
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "Refusing to upgrade: another Importarr upgrade is already running." >&2
exit 1
fi
if [ ! -d "$REPO_DIR/.git" ]; then if [ ! -d "$REPO_DIR/.git" ]; then
echo "Importarr repo not found at $REPO_DIR" >&2 echo "Importarr repo not found at $REPO_DIR" >&2
@@ -29,17 +46,33 @@ if [ -n "$(git status --porcelain)" ]; then
exit 1 exit 1
fi fi
git fetch --prune origin if [ -n "$REVISION" ]; then
git pull --ff-only test "$(git rev-parse HEAD)" = "$(git rev-parse "$REVISION^{commit}")"
"$VENV/bin/pip" install --upgrade "$REPO_DIR" else
git fetch --prune origin main
git checkout -B main origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
fi
"$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir "${WHEEL:-$REPO_DIR}"
if [ -f "$ENV_FILE" ]; then
sed -i '/^[[:space:]]*IMPORTARR_UPDATE_COMMAND=/d' "$ENV_FILE"
fi
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" "$PREFIX/repo-upgrade.sh"
systemctl daemon-reload 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 VERSION="$(git rev-parse --short=12 HEAD)"
BUILD_ENV_TMP="$PREFIX/build.env.tmp.$$"
trap 'rm -f "$BUILD_ENV_TMP"' EXIT HUP INT TERM
cat > "$BUILD_ENV_TMP" <<EOF
IMPORTARR_VERSION=$VERSION
IMPORTARR_GIT_SHA=$GIT_SHA IMPORTARR_GIT_SHA=$GIT_SHA
IMPORTARR_BUILD_DATE=$BUILD_DATE IMPORTARR_BUILD_DATE=$BUILD_DATE
EOF EOF
chmod 0644 "$BUILD_ENV_TMP"
mv -f "$BUILD_ENV_TMP" "$PREFIX/build.env"
trap - EXIT HUP INT TERM
systemctl restart "$SERVICE" systemctl restart "$SERVICE"
systemctl restart manual-media-import.timer systemctl restart manual-media-import.timer
systemctl --no-pager --full status "$SERVICE" systemctl --no-pager --full status "$SERVICE"
-108
View File
@@ -1,108 +0,0 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_dir=$(CDPATH= cd -- "$script_dir/../.." && pwd)
compose_file="$repo_dir/deploy/docker-compose.review.yml"
env_file="$repo_dir/deploy/importarr.review.env"
env_example="$repo_dir/deploy/importarr.review.env.example"
data_dir="$repo_dir/.review-data"
repo_name=$(printf '%s' "${repo_dir##*/}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-')
repo_id=$(printf '%s' "$repo_dir" | cksum | awk '{print $1}')
project_name="importarr-review-${repo_name}-${repo_id}"
review_image="${project_name}-importarr:review"
usage() {
echo "Usage: $0 {up|update|status|logs|stop|down|reset}" >&2
exit 2
}
command -v docker >/dev/null 2>&1 || {
echo "docker is required" >&2
exit 1
}
if docker info >/dev/null 2>&1; then
docker_with_sudo=false
elif command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then
docker_with_sudo=true
else
echo "cannot access the Docker daemon with docker or sudo -n docker" >&2
exit 1
fi
compose() {
selected_env_file=$env_example
if [ -f "$env_file" ]; then
selected_env_file=$env_file
fi
if [ "$docker_with_sudo" = true ]; then
sudo -n env REVIEW_IMAGE="$review_image" REVIEW_SERVICE_ENV_FILE="$selected_env_file" \
docker compose --project-name "$project_name" --project-directory "$repo_dir/deploy" \
--env-file "$selected_env_file" -f "$compose_file" "$@"
else
REVIEW_IMAGE="$review_image" REVIEW_SERVICE_ENV_FILE="$selected_env_file" \
docker compose --project-name "$project_name" --project-directory "$repo_dir/deploy" \
--env-file "$selected_env_file" -f "$compose_file" "$@"
fi
}
ensure_env_file() {
if [ ! -f "$env_file" ]; then
cp "$env_example" "$env_file"
echo "Created $env_file from the safe review defaults."
fi
}
remove_data() {
if rm -rf "$data_dir" 2>/dev/null && [ ! -e "$data_dir" ]; then
return
fi
if [ "$docker_with_sudo" = true ]; then
sudo -n rm -rf "$data_dir"
else
echo "cannot remove root-owned review data without passwordless sudo: $data_dir" >&2
exit 1
fi
}
case "${1:-}" in
up|update)
ensure_env_file
mkdir -p "$data_dir/config" "$data_dir/downloads" "$data_dir/movies" "$data_dir/tv"
compose up --detach --build --wait
;;
status)
compose ps
container_id=$(compose ps --quiet importarr)
[ -n "$container_id" ] || {
echo "review service is not running" >&2
exit 1
}
health=$(if [ "$docker_with_sudo" = true ]; then
sudo -n docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id"
else
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id"
fi)
[ "$health" = healthy ] || {
echo "review service is not healthy (status: $health)" >&2
exit 1
}
;;
logs)
compose logs --follow --tail=200
;;
stop)
compose stop
;;
down)
compose down --remove-orphans
;;
reset)
compose down --remove-orphans
remove_data
echo "Removed review data: $data_dir"
;;
*) usage ;;
esac
+16 -5
View File
@@ -11,25 +11,36 @@ install -d -m 0755 /opt/importarr
REPO_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" REPO_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
python3 -m venv /opt/importarr/venv python3 -m venv /opt/importarr/venv
/opt/importarr/venv/bin/pip install --upgrade pip /opt/importarr/venv/bin/pip install --upgrade pip
/opt/importarr/venv/bin/pip install --upgrade "$REPO_DIR" /opt/importarr/venv/bin/pip install --upgrade --force-reinstall --no-cache-dir "$REPO_DIR"
if [ ! -f /etc/importarr/importarr.env ]; then if [ ! -f /etc/importarr/importarr.env ]; then
install -m 0600 "$REPO_DIR/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
if ! grep -q '^IMPORTARR_REPO_DIR=' /etc/importarr/importarr.env; then
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 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
install -m 0644 "$REPO_DIR/deploy/manual-media-import.service" /etc/systemd/system/manual-media-import.service install -m 0644 "$REPO_DIR/deploy/manual-media-import.service" /etc/systemd/system/manual-media-import.service
install -m 0644 "$REPO_DIR/deploy/manual-media-import.timer" /etc/systemd/system/manual-media-import.timer install -m 0644 "$REPO_DIR/deploy/manual-media-import.timer" /etc/systemd/system/manual-media-import.timer
install -m 0644 "$REPO_DIR/deploy/manual-media-import-failure.service" /etc/systemd/system/manual-media-import-failure.service install -m 0644 "$REPO_DIR/deploy/manual-media-import-failure.service" /etc/systemd/system/manual-media-import-failure.service
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" /opt/importarr/repo-upgrade.sh install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" /opt/importarr/repo-upgrade.sh
if [ "$REPO_DIR" != /opt/importarr/repo ]; then
if [ -e /opt/importarr/repo ] && [ ! -L /opt/importarr/repo ]; then
echo "Refusing to replace existing /opt/importarr/repo; move it or set IMPORTARR_REPO_DIR explicitly." >&2
exit 1
fi
ln -sfn "$REPO_DIR" /opt/importarr/repo
fi
GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short=12 HEAD 2>/dev/null || printf development)" GIT_SHA="$(git -C "$REPO_DIR" 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 > /opt/importarr/build.env <<EOF VERSION="$(git -C "$REPO_DIR" describe --tags --exact-match HEAD 2>/dev/null || /opt/importarr/venv/bin/python -c 'from importarr import __version__; print(__version__)')"
BUILD_ENV_TMP="/opt/importarr/build.env.tmp.$$"
trap 'rm -f "$BUILD_ENV_TMP"' EXIT HUP INT TERM
cat > "$BUILD_ENV_TMP" <<EOF
IMPORTARR_VERSION=$VERSION
IMPORTARR_GIT_SHA=$GIT_SHA IMPORTARR_GIT_SHA=$GIT_SHA
IMPORTARR_BUILD_DATE=$BUILD_DATE IMPORTARR_BUILD_DATE=$BUILD_DATE
EOF EOF
chmod 0644 "$BUILD_ENV_TMP"
mv -f "$BUILD_ENV_TMP" /opt/importarr/build.env
trap - EXIT HUP INT TERM
systemctl daemon-reload systemctl daemon-reload
systemctl enable importarr.service systemctl enable importarr.service
systemctl enable manual-media-import.timer systemctl enable manual-media-import.timer
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "importarr-ui", "name": "importarr-ui",
"version": "0.1.0", "version": "0.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "importarr-ui", "name": "importarr-ui",
"version": "0.1.0", "version": "0.1.3",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "importarr-ui", "name": "importarr-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+15 -9
View File
@@ -1,22 +1,29 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { AlertTriangle, Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react"; import { Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react";
import "./globals.css"; import "./globals.css";
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui"; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui";
const groups = ["sab_processing", "ready", "importing", "failed", "ignored_category", "manual_batch", "completed"]; const groups = ["ready", "importing", "failed", "manual_batch", "completed", "ignored_category", "sab_processing"];
const labels = { sab_processing: "SAB processing", ready: "Ready", importing: "Importing", failed: "Failed", ignored_category: "Ignored category", manual_batch: "Manual batch", completed: "Completed" }; const labels = { sab_processing: "SAB processing", ready: "Ready", importing: "Importing", failed: "Failed", ignored_category: "Ignored category", manual_batch: "Manual batch", completed: "Completed" };
const bytes = value => { let n=Number(value||0), i=0; const units=["B","KB","MB","GB","TB"]; if(!n)return "size unknown"; while(n>=1024&&i<4){n/=1024;i++} return `${n.toFixed(n>=10||!i?0:1)} ${units[i]}`; }; const bytes = value => { let n=Number(value||0), i=0; const units=["B","KB","MB","GB","TB"]; if(!n)return "size unknown"; while(n>=1024&&i<4){n/=1024;i++} return `${n.toFixed(n>=10||!i?0:1)} ${units[i]}`; };
const duration = value => { const s=Math.max(0,Math.floor(Number(value||0))); return s>=60?`${Math.floor(s/60)}m ${String(s%60).padStart(2,"0")}s`:`${s}s`; }; const duration = value => { const s=Math.max(0,Math.floor(Number(value||0))); return s>=60?`${Math.floor(s/60)}m ${String(s%60).padStart(2,"0")}s`:`${s}s`; };
async function request(url, options={}) { export async function request(url, requestOptions={}, retryAuth=true) {
const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...options.headers},body:JSON.stringify(options.body)} : options); const { auth=false, ...options }=requestOptions;
const protectedRequest=auth||(options.method&&options.method!=="GET"), token=protectedRequest?sessionStorage.getItem("importarr-auth-token"):null;
const headers={...options.headers,...(token?{Authorization:`Bearer ${token}`}:{})};
const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...headers},body:JSON.stringify(options.body)} : {...options,headers});
if(response.status===401&&protectedRequest&&retryAuth){const entered=prompt("Importarr write access token");if(entered){sessionStorage.setItem("importarr-auth-token",entered);return request(url,requestOptions,false)}}
if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); throw new Error(typeof error.detail==="string"?error.detail:"Request failed"); } if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); throw new Error(typeof error.detail==="string"?error.detail:"Request failed"); }
return response.status===204?null:response.json(); return response.status===204?null:response.json();
} }
export const pollDelay = failures => failures ? Math.min(5000,1000*2**(failures-1)) : 300;
function Modal({ trigger, title, children }) { return <Dialog><DialogTrigger asChild>{trigger}</DialogTrigger><DialogContent><DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>{children}</DialogContent></Dialog>; } function Modal({ trigger, title, children }) { return <Dialog><DialogTrigger asChild>{trigger}</DialogTrigger><DialogContent><DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>{children}</DialogContent></Dialog>; }
function Field({ label, ...props }) { return <label className="grid gap-2 text-sm font-medium">{label}<Input {...props}/></label>; } function Field({ label, ...props }) { return <label className="grid gap-2 text-sm font-medium">{label}<Input {...props}/></label>; }
export function BuildSummary({ build }) { return <p className="hidden max-w-md truncate text-xs text-muted-foreground sm:block">{build?`Build ${build.git_sha} · ${build.build_date}`:"Build information unavailable"}</p>; }
export function SettingsDialog({ status, refresh }) { export function SettingsDialog({ status, refresh }) {
const [result,setResult]=useState({}); const [result,setResult]=useState({});
@@ -30,20 +37,19 @@ export function SettingsDialog({ status, refresh }) {
function ManualBatches({ refresh }) { const [batches,setBatches]=useState([]); const load=()=>request("/api/manual-batches").then(setBatches).catch(()=>{}); return <Modal title="Manual batches" trigger={<Button variant="ghost" className="w-full justify-start" onClick={load}><FolderPlus/> Manual batches</Button>}><form className="flex flex-col gap-3 sm:flex-row" onSubmit={async e=>{e.preventDefault();try{await request("/api/manual-batches",{method:"POST",body:{path:e.currentTarget.path.value}});e.currentTarget.reset();load();refresh()}catch(error){alert(error.message)}}}><Input name="path" required placeholder="Folder under download root"/><Button>Add batch</Button></form><div className="mt-4 grid gap-2">{batches.length?batches.map(b=><div className="rounded-md border p-3 text-sm" key={b.id}><strong>#{b.id} · {b.status}</strong><p className="break-all text-muted-foreground">{b.path}</p></div>):<p className="text-muted-foreground">No manual batches.</p>}</div></Modal>; } function ManualBatches({ refresh }) { const [batches,setBatches]=useState([]); const load=()=>request("/api/manual-batches").then(setBatches).catch(()=>{}); return <Modal title="Manual batches" trigger={<Button variant="ghost" className="w-full justify-start" onClick={load}><FolderPlus/> Manual batches</Button>}><form className="flex flex-col gap-3 sm:flex-row" onSubmit={async e=>{e.preventDefault();try{await request("/api/manual-batches",{method:"POST",body:{path:e.currentTarget.path.value}});e.currentTarget.reset();load();refresh()}catch(error){alert(error.message)}}}><Input name="path" required placeholder="Folder under download root"/><Button>Add batch</Button></form><div className="mt-4 grid gap-2">{batches.length?batches.map(b=><div className="rounded-md border p-3 text-sm" key={b.id}><strong>#{b.id} · {b.status}</strong><p className="break-all text-muted-foreground">{b.path}</p></div>):<p className="text-muted-foreground">No manual batches.</p>}</div></Modal>; }
export function App(){ export function App(){
const [status,setStatus]=useState(null), [jobs,setJobs]=useState([]), [update,setUpdate]=useState(null), [menu,setMenu]=useState(false); const [status,setStatus]=useState(null), [jobs,setJobs]=useState([]), [menu,setMenu]=useState(false);
const [dark,setDark]=useState(()=>localStorage.getItem("importarr-theme")!=="light" && (localStorage.getItem("importarr-theme")==="dark"||matchMedia("(prefers-color-scheme: dark)").matches)); const [dark,setDark]=useState(()=>localStorage.getItem("importarr-theme")!=="light" && (localStorage.getItem("importarr-theme")==="dark"||matchMedia("(prefers-color-scheme: dark)").matches));
const refresh=async()=>{ try { const [s,j]=await Promise.all([request("/api/status"),request("/api/jobs")]); setStatus(s);setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})));document.title=`${j.jobs?.length?`${j.jobs.length} jobs`:"Idle"} · Importarr`; } catch(error){ console.error(error); } }; const refresh=async()=>{ try { const [s,j]=await Promise.all([request("/api/status"),request("/api/jobs")]); setStatus(s);setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})));document.title=`${j.jobs?.length?`${j.jobs.length} jobs`:"Idle"} · Importarr`; } catch(error){ console.error(error); } };
useEffect(()=>{document.documentElement.classList.toggle("dark",dark);localStorage.setItem("importarr-theme",dark?"dark":"light")},[dark]); useEffect(()=>{document.documentElement.classList.toggle("dark",dark);localStorage.setItem("importarr-theme",dark?"dark":"light")},[dark]);
useEffect(()=>{refresh();const id=setInterval(refresh,2000);request("/api/control/update-check").then(setUpdate).catch(()=>{});return()=>clearInterval(id)},[]); useEffect(()=>{let stopped=false,lastCurrent,lastJobs=0,failures=0;const poll=async()=>{try{const s=await request("/api/status");if(stopped)return;setStatus(s);const current=typeof s.current==="object"?(s.current.file||s.current.name):s.current;if(current!==lastCurrent||Date.now()-lastJobs>(current?10000:2000)){lastCurrent=current;lastJobs=Date.now();const j=await request("/api/jobs");setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})))}failures=0}catch(error){failures++;console.error(error)}if(!stopped)setTimeout(poll,pollDelay(failures))};poll();return()=>{stopped=true}},[]);
const post=async(url,body)=>{try{await request(url,{method:"POST",body});await refresh()}catch(error){alert(error.message)}}; const post=async(url,body)=>{try{await request(url,{method:"POST",body});await refresh()}catch(error){alert(error.message)}};
const control=action=>{if(action==="cancel-current"&&!confirm("Cancel the current import job?"))return;post(`/api/control/${action}`)}; const control=action=>{if(action==="cancel-current"&&!confirm("Cancel the current import job?"))return;post(`/api/control/${action}`)};
const current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current; const current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current;
return <div className="min-h-screen bg-background"> return <div className="min-h-screen bg-background">
<header className="sticky top-0 z-30 border-b bg-card/95 backdrop-blur"><div className="mx-auto flex max-w-7xl items-center gap-3 p-4"><Download className="h-7 w-7 text-accent"/><div><h1 className="text-xl font-bold">Importarr</h1><p className="text-xs capitalize text-muted-foreground">{status?.control?.queue_mode||"Connecting"}</p></div><div className="ml-auto hidden max-w-md truncate text-sm text-muted-foreground sm:block">{currentName||"No active import"}</div><Button size="icon" aria-label="Start imports" onClick={()=>control("start")}><Play/></Button><Button size="icon" variant="outline" aria-label="Pause imports" onClick={()=>control("pause")}><Pause/></Button><div className="relative"><Button size="icon" variant="outline" aria-label="Open menu" onClick={()=>setMenu(!menu)}><Menu/></Button>{menu&&<Card className="absolute right-0 mt-2 w-60 p-2"><SettingsDialog status={status} refresh={refresh}/><ManualBatches refresh={refresh}/><Modal title="Service info" trigger={<Button variant="ghost" className="w-full justify-start"><Info/> Service info</Button>}><dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">{status&&Object.entries({Version:status.build.version,"Build date":status.build.build_date,"Git SHA":status.build.git_sha,"SAB URL":status.sab_url,"Download root":status.download_root,"Movies root":status.movies_root,"TV root":status.tv_root,"Write auth":status.auth_enabled?"enabled":"disabled"}).map(([k,v])=><React.Fragment key={k}><dt className="font-medium">{k}</dt><dd className="break-all text-muted-foreground">{String(v)}</dd></React.Fragment>)}</dl></Modal><Button variant="ghost" className="w-full justify-start" onClick={()=>setDark(!dark)}>{dark?<Sun/>:<Moon/>}{dark?"Light":"Dark"} theme</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("stop")}><Square/> Stop queue</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("cancel-current")}><XCircle/> Cancel current</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>post("/api/import/run-now",{force:true})}><Zap/> Force run now</Button></Card>}</div></div></header> <header className="sticky top-0 z-30 border-b bg-card/95 backdrop-blur"><div className="mx-auto flex max-w-7xl items-center gap-3 p-4"><Download className="h-7 w-7 text-accent"/><div><h1 className="text-xl font-bold">Importarr</h1><p className="text-xs capitalize text-muted-foreground">{status?.control?.queue_mode||"Connecting"}</p><BuildSummary build={status?.build}/></div><div className="ml-auto hidden max-w-md truncate text-sm text-muted-foreground sm:block">{currentName||"No active import"}</div><Button size="icon" aria-label="Start imports" onClick={()=>control("start")}><Play/></Button><Button size="icon" variant="outline" aria-label="Pause imports" onClick={()=>control("pause")}><Pause/></Button><div className="relative"><Button size="icon" variant="outline" aria-label="Open menu" onClick={()=>setMenu(!menu)}><Menu/></Button>{menu&&<Card className="absolute right-0 mt-2 w-60 p-2"><SettingsDialog status={status} refresh={refresh}/><ManualBatches refresh={refresh}/><Modal title="Service info" trigger={<Button variant="ghost" className="w-full justify-start"><Info/> Service info</Button>}><dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">{status&&Object.entries({Version:status.build.version,"Build date":status.build.build_date,"Git SHA":status.build.git_sha,"SAB URL":status.sab_url,"Download root":status.download_root,"Movies root":status.movies_root,"TV root":status.tv_root,"Write auth":status.auth_enabled?"enabled":"disabled"}).map(([k,v])=><React.Fragment key={k}><dt className="font-medium">{k}</dt><dd className="break-all text-muted-foreground">{String(v)}</dd></React.Fragment>)}</dl></Modal><Button variant="ghost" className="w-full justify-start" onClick={()=>setDark(!dark)}>{dark?<Sun/>:<Moon/>}{dark?"Light":"Dark"} theme</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("stop")}><Square/> Stop queue</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("cancel-current")}><XCircle/> Cancel current</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>post("/api/import/run-now",{force:true})}><Zap/> Force run now</Button></Card>}</div></div></header>
<main className="mx-auto grid max-w-7xl gap-4 p-4 sm:p-6"> <main className="mx-auto grid max-w-7xl gap-4 p-4 sm:p-6">
{update?.update_available&&<Card className="border-secondary"><CardContent className="flex flex-wrap items-center gap-3 p-4"><AlertTriangle className="text-accent"/><strong>Version {update.latest_version} is available</strong><Button className="ml-auto" onClick={()=>confirm("Update Importarr now?")&&post("/api/control/update")}>Update now</Button></CardContent></Card>}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=><Card key={label}><CardContent className="p-4"><strong className="block truncate text-lg capitalize">{value??"—"}</strong><span className="text-sm text-muted-foreground">{label}</span></CardContent></Card>)}</div> <div className="grid grid-cols-2 gap-4 lg:grid-cols-5">{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=><Card key={label}><CardContent className="p-4"><strong className="block truncate text-lg capitalize">{value??"—"}</strong><span className="text-sm text-muted-foreground">{label}</span></CardContent></Card>)}</div>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>Current import</CardTitle><p className="text-sm text-muted-foreground">{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}</p></div><Clock className="text-accent"/></CardHeader><CardContent><p className="mb-3 break-all text-sm">{currentName||"No active copy."}</p><progress className="h-2 w-full accent-accent" max="100" value={typeof current==="object"?current.percent||0:0}/>{currentName&&<p className="mt-2 text-xs text-muted-foreground">{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}</p>}</CardContent></Card> <Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>Current import</CardTitle><p className="text-sm text-muted-foreground">{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}</p></div><Clock className="text-accent"/></CardHeader><CardContent><p className="mb-3 break-all text-sm">{currentName||"No active copy."}</p>{currentName&&<><progress className="h-2 w-full accent-accent" max="100" value={typeof current==="object"?current.percent||0:0}/><p className="mt-2 text-xs text-muted-foreground">{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}</p></>}</CardContent></Card>
<Card><CardHeader className="flex-row items-center gap-3"><History className="text-accent"/><div><CardTitle>Queue and history</CardTitle><p className="text-sm text-muted-foreground">Grouped by processing state</p></div></CardHeader><CardContent className="grid gap-6">{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?<section key={group}><h3 className="mb-3 flex items-center gap-2 font-semibold">{labels[group]} <Badge>{items.length}</Badge></h3><div className="grid gap-2">{items.map(j=><article className="grid gap-3 rounded-md border bg-input p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" key={j.id}><div className="min-w-0"><strong className="block break-words">{j.name}</strong><p className="break-all text-xs text-muted-foreground">{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}</p><div className="mt-2 flex flex-wrap items-center gap-2"><Badge>{j.state}</Badge><span className="text-xs text-muted-foreground">{j.reason}</span></div></div><div className="flex gap-2">{j.can_run_now&&<Button size="icon" title="Run now" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"run-now"})}><Play/></Button>}{j.can_retry&&<Button size="icon" variant="outline" title="Retry" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"retry"})}><RefreshCw/></Button>}{j.can_ignore&&<Button size="icon" variant="outline" title="Ignore" onClick={()=>confirm("Ignore this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"ignore"})}><XCircle/></Button>}{j.can_remove&&<Button size="icon" variant="outline" title="Remove" onClick={()=>confirm("Remove this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"remove"})}><Trash2/></Button>}</div></article>)}</div></section>:null}):<p className="text-muted-foreground">No queue items.</p>}</CardContent></Card> <Card><CardHeader className="flex-row items-center gap-3"><History className="text-accent"/><div><CardTitle>Queue and history</CardTitle><p className="text-sm text-muted-foreground">Grouped by processing state</p></div></CardHeader><CardContent className="grid gap-6">{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?<section key={group}><h3 className="mb-3 flex items-center gap-2 font-semibold">{labels[group]} <Badge>{items.length}</Badge></h3><div className="grid gap-2">{items.map(j=><article className="grid gap-3 rounded-md border bg-input p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" key={j.id}><div className="min-w-0"><strong className="block break-words">{j.name}</strong><p className="break-all text-xs text-muted-foreground">{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}</p><div className="mt-2 flex flex-wrap items-center gap-2"><Badge>{j.state}</Badge><span className="text-xs text-muted-foreground">{j.reason}</span></div></div><div className="flex gap-2">{j.can_run_now&&<Button size="icon" title="Run now" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"run-now"})}><Play/></Button>}{j.can_retry&&<Button size="icon" variant="outline" title="Retry" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"retry"})}><RefreshCw/></Button>}{j.can_ignore&&<Button size="icon" variant="outline" title="Ignore" onClick={()=>confirm("Ignore this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"ignore"})}><XCircle/></Button>}{j.can_remove&&<Button size="icon" variant="outline" title="Remove" onClick={()=>confirm("Remove this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"remove"})}><Trash2/></Button>}</div></article>)}</div></section>:null}):<p className="text-muted-foreground">No queue items.</p>}</CardContent></Card>
</main></div> </main></div>
} }
+19 -2
View File
@@ -1,10 +1,11 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsDialog } from "./main"; import { BuildSummary, pollDelay, request, SettingsDialog } from "./main";
describe("Importarr UI behavior", () => { describe("Importarr UI behavior", () => {
beforeEach(() => vi.stubGlobal("fetch", vi.fn())); beforeEach(() => { sessionStorage.clear(); vi.stubGlobal("fetch", vi.fn()); });
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it("hydrates settings URLs when status arrives asynchronously", async () => { it("hydrates settings URLs when status arrives asynchronously", async () => {
@@ -26,4 +27,20 @@ describe("Importarr UI behavior", () => {
await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/import/run-now", expect.objectContaining({ method: "POST", body: JSON.stringify({ force: true }) }))); await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/import/run-now", expect.objectContaining({ method: "POST", body: JSON.stringify({ force: true }) })));
}); });
it("prompts for write auth and retries with the entered token", async () => {
fetch.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ detail: "auth required" }) }).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ok: true }) });
vi.stubGlobal("prompt", vi.fn(() => "browser-token"));
await request("/api/queue-items/1/action", { method: "POST", body: { action: "run-now" } });
expect(fetch).toHaveBeenLastCalledWith("/api/queue-items/1/action", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) }));
});
it("backs polling off after failures and immediately recovers its fast interval", () => {
expect([pollDelay(0),pollDelay(1),pollDelay(2),pollDelay(4)]).toEqual([300,1000,2000,5000]);
});
it("shows the build commit and date in the header summary", () => {
render(<BuildSummary build={{ git_sha: "abc123def456", build_date: "2026-08-11T12:34:56+02:00" }} />);
expect(screen.getByText("Build abc123def456 · 2026-08-11T12:34:56+02:00")).toBeInTheDocument();
});
}); });
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.0" __version__ = "0.1.3"
-6
View File
@@ -25,9 +25,6 @@ class Settings(BaseModel):
start_command: list[str] = Field(default_factory=lambda: ["systemctl", "start", "manual-media-import.service"]) start_command: list[str] = Field(default_factory=lambda: ["systemctl", "start", "manual-media-import.service"])
stop_command: list[str] = Field(default_factory=lambda: ["systemctl", "stop", "manual-media-import.service"]) stop_command: list[str] = Field(default_factory=lambda: ["systemctl", "stop", "manual-media-import.service"])
restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "manual-media-import.service"]) restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "manual-media-import.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) 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
@@ -53,9 +50,6 @@ class Settings(BaseModel):
start_command=_env_command("IMPORTARR_START_COMMAND", ["systemctl", "start", "manual-media-import.service"]), start_command=_env_command("IMPORTARR_START_COMMAND", ["systemctl", "start", "manual-media-import.service"]),
stop_command=_env_command("IMPORTARR_STOP_COMMAND", ["systemctl", "stop", "manual-media-import.service"]), stop_command=_env_command("IMPORTARR_STOP_COMMAND", ["systemctl", "stop", "manual-media-import.service"]),
restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "manual-media-import.service"]), restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "manual-media-import.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")), 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")),
+4 -63
View File
@@ -60,14 +60,6 @@ class ControlCommandResponse(BaseModel):
stderr: 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): class AppSettingsUpdate(BaseModel):
sab_url: str sab_url: str
sab_api_key: str | None = None sab_api_key: str | None = None
@@ -95,7 +87,9 @@ 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 if settings.bind_host in {"127.0.0.1", "::1", "localhost"}:
return
raise HTTPException(status_code=503, detail="write endpoints require IMPORTARR_AUTH_TOKEN on a non-loopback bind")
if authorization != f"Bearer {settings.auth_token}": if authorization != f"Bearer {settings.auth_token}":
raise HTTPException(status_code=401, detail="write endpoint requires bearer token") raise HTTPException(status_code=401, detail="write endpoint requires bearer token")
@@ -284,6 +278,7 @@ def _worker_loop() -> None:
item = state.claim_next_queue_item(WORKER_ID) if queue_accepting_new_jobs() else None item = state.claim_next_queue_item(WORKER_ID) if queue_accepting_new_jobs() else None
if item is not None: if item is not None:
_import_queue_item(item, importer, from_worker=True) _import_queue_item(item, importer, from_worker=True)
continue
except Exception: except Exception:
state.upsert_queue_item(source_type="system", source_id="queue-worker", name="Queue worker", state="failed", reason="worker loop error") state.upsert_queue_item(source_type="system", source_id="queue-worker", name="Queue worker", state="failed", reason="worker loop error")
_worker_stop.wait(settings.poll_seconds) _worker_stop.wait(settings.poll_seconds)
@@ -335,60 +330,6 @@ def restart_service(_: None = Depends(require_write_auth)) -> dict[str, object]:
return _run_control_command(settings.restart_command) 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]: def _run_control_command(command: list[str]) -> dict[str, object]:
if not command: if not command:
raise HTTPException(status_code=500, detail="control command is not configured") raise HTTPException(status_code=500, detail="control command is not configured")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "importarr" name = "importarr"
version = "0.1.0" version = "0.1.3"
description = "Arr-style manual SABnzbd import service" description = "Arr-style manual SABnzbd import service"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -23,6 +23,9 @@ test = ["pytest>=8.2", "pytest-asyncio>=0.23"]
importarr = "importarr.main:run" importarr = "importarr.main:run"
manual-media-import = "importarr.worker:main" manual-media-import = "importarr.worker:main"
[tool.hatch.build.targets.wheel]
packages = ["importarr"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."] pythonpath = ["."]
+99
View File
@@ -0,0 +1,99 @@
import fcntl
import os
import subprocess
import sys
import zipfile
from pathlib import Path
def test_repo_upgrade_refuses_concurrent_run(tmp_path):
script = Path(__file__).parents[1] / "deploy" / "repo-upgrade.sh"
lock_path = tmp_path / "repo-upgrade.lock"
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake_id = bin_dir / "id"
fake_id.write_text("#!/bin/sh\nprintf '0\\n'\n")
fake_id.chmod(0o755)
with lock_path.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
result = subprocess.run(
["sh", str(script)],
env={
**os.environ,
"PATH": f"{bin_dir}:{os.environ['PATH']}",
"IMPORTARR_ENV_FILE": str(tmp_path / "missing.env"),
"IMPORTARR_PREFIX": str(tmp_path),
},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert "another Importarr upgrade is already running" in result.stderr
def test_wheel_contains_built_frontend_assets(tmp_path):
root = Path(__file__).parents[1]
subprocess.run(
[sys.executable, "-m", "pip", "wheel", "--no-deps", "--wheel-dir", str(tmp_path), str(root)],
check=True,
capture_output=True,
text=True,
)
wheel = next(tmp_path.glob("importarr-*.whl"))
with zipfile.ZipFile(wheel) as archive:
packaged = set(archive.namelist())
assert "importarr/static/index.html" in packaged
assert "importarr/static/assets/app.js" in packaged
assert "importarr/static/assets/app.css" in packaged
def test_repo_upgrade_forces_fresh_install_without_skipping_dependencies():
script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text()
assert "pip\" install --upgrade --force-reinstall --no-cache-dir" in script
assert "--no-deps" not in script
assert '"${WHEEL:-$REPO_DIR}"' in script
def test_deploy_workflow_pins_repository_to_triggering_revision():
workflow = (Path(__file__).parents[1] / ".gitea/workflows/deploy.yml").read_text()
assert "DEPLOY_SHA: ${{ github.sha }}" in workflow
assert "git fetch --prune origin main" in workflow
assert "git checkout --detach $DEPLOY_SHA" in workflow
assert 'repo-upgrade.sh \\"\\" $DEPLOY_SHA' in workflow
def test_deploy_workflow_runs_repository_upgrade_over_ssh():
workflow = (Path(__file__).parents[1] / ".gitea/workflows/deploy.yml").read_text()
assert 'ssh -p "$DEPLOY_PORT" -o BatchMode=yes "$DEPLOY_USER@$DEPLOY_HOST"' in workflow
assert 'test -z \\"\\$(git status --porcelain)\\"' in workflow
assert "sudo -n /bin/sh -c 'set -eu; cd /opt/importarr/repo" in workflow
def test_repo_upgrade_uses_pinned_revision_for_build_metadata():
script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text()
assert 'test "$(git rev-parse HEAD)" = "$(git rev-parse "$REVISION^{commit}")"' in script
assert 'GIT_SHA="$(git rev-parse --short=12 HEAD' in script
assert 'VERSION="$(git rev-parse --short=12 HEAD)"' in script
def test_repo_upgrade_deploys_only_origin_main():
script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text()
assert "git fetch --prune origin main" in script
assert "git checkout -B main origin/main" in script
assert "refs/tags" not in script
def test_repo_upgrade_removes_obsolete_update_command():
script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text()
assert "IMPORTARR_UPDATE_COMMAND=/d" in script
-76
View File
@@ -453,82 +453,6 @@ def test_worker_failure_stops_retrying_after_limit(tmp_path, monkeypatch):
assert updated["attempt_count"] == main.MAX_RETRY_ATTEMPTS assert updated["attempt_count"] == main.MAX_RETRY_ATTEMPTS
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): def test_control_restart_reports_command_failure(tmp_path, monkeypatch):
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch) main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
main.settings.restart_command = ["restart"] main.settings.restart_command = ["restart"]
+47 -1
View File
@@ -1,3 +1,6 @@
import pytest
def test_health_contains_build_info(tmp_path, monkeypatch): def test_health_contains_build_info(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
@@ -21,6 +24,16 @@ def test_build_date_is_rendered_in_local_time(monkeypatch):
assert build_info.build_info()["build_date"] == "2026-07-29T14:00:00+02:00" assert build_info.build_info()["build_date"] == "2026-07-29T14:00:00+02:00"
def test_build_info_prefers_installed_release_provenance(monkeypatch):
import importarr.build_info as build_info
monkeypatch.setenv("IMPORTARR_VERSION", "v1.2.3")
monkeypatch.setenv("IMPORTARR_GIT_SHA", "abc123")
assert build_info.build_info()["version"] == "v1.2.3"
assert build_info.build_info()["git_sha"] == "abc123"
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
@@ -56,7 +69,7 @@ def test_frontend_uses_required_stack_and_capabilities():
assert "lucide-react" in package assert "lucide-react" in package
assert "@radix-ui/react-dialog" in package assert "@radix-ui/react-dialog" in package
assert "tailwindcss" in package assert "tailwindcss" in package
for endpoint in ("/api/jobs", "/api/settings", "/api/manual-batches", "/api/control/update", "/api/import/run-now"): for endpoint in ("/api/jobs", "/api/settings", "/api/manual-batches", "/api/import/run-now"):
assert endpoint in app assert endpoint in app
@@ -132,3 +145,36 @@ def test_connection_test_rejects_unknown_service(tmp_path, monkeypatch):
) )
assert response.status_code == 400 assert response.status_code == 400
def test_control_endpoints_require_configured_bearer_token(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
monkeypatch.setattr(main.settings, "auth_token", "test-token")
client = TestClient(main.app)
for method, endpoint in (("post", "/api/control/restart"),):
assert getattr(client, method)(endpoint).status_code == 401
assert getattr(client, method)(endpoint, headers={"Authorization": "Bearer wrong"}).status_code == 401
def test_write_endpoint_fails_closed_without_token_on_non_loopback_bind(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi.testclient import TestClient
monkeypatch.setattr(main.settings, "auth_token", None)
monkeypatch.setattr(main.settings, "bind_host", "0.0.0.0")
assert TestClient(main.app).post("/api/control/restart").status_code == 503
def test_tokenless_local_development_remains_available(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
monkeypatch.setattr(main.settings, "auth_token", None)
monkeypatch.setattr(main.settings, "bind_host", "127.0.0.1")
main.require_write_auth()