6 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
25 changed files with 256 additions and 511 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 -8
View File
@@ -1,11 +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.
## Release workflow
Generic requests such as "release app", "release to live", or "release importarr" mean the complete release-to-live flow: verify a clean release candidate, run tests, commit the intended changes, create and push a SemVer tag and repository release, call the live bearer's protected `POST /api/control/update?expected_tag=<release-tag>` with the required exact release tag, then verify health and that the reported installed version matches the release. Follow any host-local operational runbook for credentials and live URLs; never put tokens or other secrets in this repository, commands shown in logs, release notes, or commits.
+1 -2
View File
@@ -4,7 +4,6 @@ SERVICE ?= importarr.service
IMPORTARR_PREFIX ?= /opt/importarr IMPORTARR_PREFIX ?= /opt/importarr
IMPORTARR_REPO_DIR ?= $(CURDIR) IMPORTARR_REPO_DIR ?= $(CURDIR)
IMPORTARR_URL ?= http://127.0.0.1:8765 IMPORTARR_URL ?= http://127.0.0.1:8765
RELEASE ?= vMAJOR.MINOR.PATCH
.PHONY: test install-systemd install-from-repo repo-upgrade verify .PHONY: test install-systemd install-from-repo repo-upgrade verify
@@ -18,7 +17,7 @@ install-from-repo:
sudo -n $(IMPORTARR_PREFIX)/venv/bin/pip install --upgrade $(IMPORTARR_REPO_DIR) sudo -n $(IMPORTARR_PREFIX)/venv/bin/pip install --upgrade $(IMPORTARR_REPO_DIR)
repo-upgrade: repo-upgrade:
sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh $(RELEASE) sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh
verify: verify:
curl -fsS $(IMPORTARR_URL)/health curl -fsS $(IMPORTARR_URL)/health
+16 -42
View File
@@ -38,14 +38,26 @@ The installer creates the `importarr` system user when needed, installs a virtua
For a machine that should stay current with the repository, use the installed repo-upgrade helper: For a machine that should stay current with the repository, use the installed repo-upgrade helper:
```sh ```sh
sudo -n sh /opt/importarr/repo-upgrade.sh v1.2.3 sudo -n sh /opt/importarr/repo-upgrade.sh
``` ```
The helper requires the intended release tag and a clean Git checkout at `/opt/importarr/repo` (override with `IMPORTARR_REPO_DIR` only for a nonstandard installation). It fetches tags, checks out that exact tag in detached-HEAD state, reinstalls the package, records tag/SHA provenance, and restarts `importarr.service`; it never installs an arbitrary branch head. 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 installed release recorded in `/opt/importarr/build.env`. `POST /api/control/update?expected_tag=v1.2.3` verifies that the latest release is the caller's expected tag, appends that exact tag to the configured command, and schedules it in a detached process after returning. A mismatch returns `409` without scheduling an upgrade. The upgrade helper holds an exclusive lock across fetch, checkout, install, provenance update, and service restart, so concurrent requests cannot overlap. Poll `/health` and `update-check` until the new process is healthy and reports the expected tag. The systemd default is the working-directory-independent `/bin/sh /opt/importarr/repo-upgrade.sh`; configure `IMPORTARR_UPDATE_COMMAND` only when the helper is installed elsewhere. 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
@@ -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?expected_tag=v1.2.3` (`expected_tag` is required)
- `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.
-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
-3
View File
@@ -16,6 +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
# Override only when the installed helper is not at the standard systemd path.
# IMPORTARR_UPDATE_COMMAND=/bin/sh /opt/importarr/repo-upgrade.sh
-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
+26 -14
View File
@@ -2,7 +2,7 @@
set -eu set -eu
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh vMAJOR.MINOR.PATCH" >&2 echo "Run as root: sudo sh /opt/importarr/repo-upgrade.sh" >&2
exit 1 exit 1
fi fi
@@ -16,12 +16,16 @@ PREFIX=${IMPORTARR_PREFIX:-/opt/importarr}
REPO_DIR=${IMPORTARR_REPO_DIR:-$PREFIX/repo} 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}
RELEASE_TAG=${1:-} if [ "$#" -gt 2 ]; then
echo "Usage: $0 [wheel [revision]]" >&2
if [ -z "$RELEASE_TAG" ]; then
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
exit 2 exit 2
fi 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} LOCK_FILE=${IMPORTARR_UPGRADE_LOCK_FILE:-$PREFIX/repo-upgrade.lock}
exec 9>"$LOCK_FILE" exec 9>"$LOCK_FILE"
@@ -42,25 +46,33 @@ if [ -n "$(git status --porcelain)" ]; then
exit 1 exit 1
fi fi
git fetch --prune --tags origin if [ -n "$REVISION" ]; then
if ! git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG" >/dev/null; then test "$(git rev-parse HEAD)" = "$(git rev-parse "$REVISION^{commit}")"
echo "Release tag not found: $RELEASE_TAG" >&2 else
exit 1 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 fi
git checkout --detach "$RELEASE_TAG"
test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_TAG"
"$VENV/bin/pip" install --upgrade "$REPO_DIR"
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" 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)"
VERSION="$RELEASE_TAG" VERSION="$(git rev-parse --short=12 HEAD)"
cat > "$PREFIX/build.env" <<EOF 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_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
+8 -3
View File
@@ -11,7 +11,7 @@ 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."
@@ -31,14 +31,19 @@ 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)"
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__)')" 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__)')"
cat > /opt/importarr/build.env <<EOF 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_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
systemctl restart importarr.service systemctl restart importarr.service
systemctl start manual-media-import.timer systemctl start manual-media-import.timer
echo "Importarr installed from $REPO_DIR. Future upgrades: sudo -n sh /opt/importarr/repo-upgrade.sh vMAJOR.MINOR.PATCH" echo "Importarr installed from $REPO_DIR. Future upgrades: sudo -n sh /opt/importarr/repo-upgrade.sh"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "importarr-ui", "name": "importarr-ui",
"version": "0.1.2", "version": "0.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "importarr-ui", "name": "importarr-ui",
"version": "0.1.2", "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.2", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+5 -5
View File
@@ -1,6 +1,6 @@
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";
@@ -23,6 +23,7 @@ export const pollDelay = failures => failures ? Math.min(5000,1000*2**(failures-
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({});
@@ -36,18 +37,17 @@ 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(()=>{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();request("/api/control/update-check",{auth:true}).then(setUpdate).catch(()=>{});return()=>{stopped=true}},[]); 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?expected_tag=${encodeURIComponent(update.latest_version)}`)}>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>{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 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>
+6 -8
View File
@@ -2,7 +2,7 @@
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 { pollDelay, request, SettingsDialog } from "./main"; import { BuildSummary, pollDelay, request, SettingsDialog } from "./main";
describe("Importarr UI behavior", () => { describe("Importarr UI behavior", () => {
beforeEach(() => { sessionStorage.clear(); vi.stubGlobal("fetch", vi.fn()); }); beforeEach(() => { sessionStorage.clear(); vi.stubGlobal("fetch", vi.fn()); });
@@ -35,14 +35,12 @@ describe("Importarr UI behavior", () => {
expect(fetch).toHaveBeenLastCalledWith("/api/queue-items/1/action", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) })); expect(fetch).toHaveBeenLastCalledWith("/api/queue-items/1/action", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) }));
}); });
it("authenticates the protected update-check GET", async () => {
fetch.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ detail: "auth required" }) }).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ update_available: false }) });
vi.stubGlobal("prompt", vi.fn(() => "browser-token"));
await request("/api/control/update-check", { auth: true });
expect(fetch).toHaveBeenLastCalledWith("/api/control/update-check", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) }));
});
it("backs polling off after failures and immediately recovers its fast interval", () => { 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]); 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.2" __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: ["/bin/sh", "/opt/importarr/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", ["/bin/sh", "/opt/importarr/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")),
-83
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
@@ -338,81 +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(expected_tag: str, _: None = Depends(require_write_auth)) -> dict[str, object]:
update = check_update_available()
if update["latest_version"] != expected_tag:
raise HTTPException(
status_code=409,
detail=f"latest release {update['latest_version']} does not match expected tag {expected_tag}",
)
if not update["update_available"]:
return {**update, "command": settings.update_command, "stdout": "", "stderr": ""}
command = [*settings.update_command, str(update["latest_version"])]
_schedule_update(command)
return {**update, "status": "update_scheduled", "command": command}
@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 _schedule_update(command: list[str]) -> None:
if not command:
raise HTTPException(status_code=500, detail="update command is not configured")
try:
subprocess.Popen(
["/bin/sh", "-c", 'sleep 1; exec "$@"', "importarr-update", *command],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"update command failed to start: {exc.__class__.__name__}") from exc
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.2" 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 = ["."]
-6
View File
@@ -15,9 +15,3 @@ def test_file_secret_env_vars_are_supported(tmp_path, monkeypatch):
assert settings.sab_api_key == "sab-secret" assert settings.sab_api_key == "sab-secret"
assert settings.auth_token == "auth-secret" assert settings.auth_token == "auth-secret"
def test_default_update_command_uses_installed_absolute_path(monkeypatch):
monkeypatch.delenv("IMPORTARR_UPDATE_COMMAND", raising=False)
assert Settings.from_env().update_command == ["/bin/sh", "/opt/importarr/repo-upgrade.sh"]
+68 -1
View File
@@ -1,6 +1,8 @@
import fcntl import fcntl
import os import os
import subprocess import subprocess
import sys
import zipfile
from pathlib import Path from pathlib import Path
@@ -16,7 +18,7 @@ def test_repo_upgrade_refuses_concurrent_run(tmp_path):
with lock_path.open("w") as lock: with lock_path.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
result = subprocess.run( result = subprocess.run(
["sh", str(script), "v1.2.3"], ["sh", str(script)],
env={ env={
**os.environ, **os.environ,
"PATH": f"{bin_dir}:{os.environ['PATH']}", "PATH": f"{bin_dir}:{os.environ['PATH']}",
@@ -30,3 +32,68 @@ def test_repo_upgrade_refuses_concurrent_run(tmp_path):
assert result.returncode == 1 assert result.returncode == 1
assert "another Importarr upgrade is already running" in result.stderr 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
-68
View File
@@ -453,74 +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_schedules_configured_command_with_release_tag(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 = []
monkeypatch.setattr(main, "_schedule_update", calls.append)
result = main.update_service(expected_tag="0.2.0")
assert result["status"] == "update_scheduled"
assert result["command"] == ["upgrade", "now", "0.2.0"]
assert calls == [["upgrade", "now", "0.2.0"]]
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(expected_tag="v0.2.0")
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"]
+2 -49
View File
@@ -69,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
@@ -156,7 +156,7 @@ def test_control_endpoints_require_configured_bearer_token(tmp_path, monkeypatch
monkeypatch.setattr(main.settings, "auth_token", "test-token") monkeypatch.setattr(main.settings, "auth_token", "test-token")
client = TestClient(main.app) client = TestClient(main.app)
for method, endpoint in (("get", "/api/control/update-check"), ("post", "/api/control/update"), ("post", "/api/control/restart")): for method, endpoint in (("post", "/api/control/restart"),):
assert getattr(client, method)(endpoint).status_code == 401 assert getattr(client, method)(endpoint).status_code == 401
assert getattr(client, method)(endpoint, headers={"Authorization": "Bearer wrong"}).status_code == 401 assert getattr(client, method)(endpoint, headers={"Authorization": "Bearer wrong"}).status_code == 401
@@ -178,50 +178,3 @@ def test_tokenless_local_development_remains_available(tmp_path, monkeypatch):
monkeypatch.setattr(main.settings, "auth_token", None) monkeypatch.setattr(main.settings, "auth_token", None)
monkeypatch.setattr(main.settings, "bind_host", "127.0.0.1") monkeypatch.setattr(main.settings, "bind_host", "127.0.0.1")
main.require_write_auth() main.require_write_auth()
def test_update_schedules_exact_latest_release_tag(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
monkeypatch.setattr(main, "check_update_available", lambda: {"status": "update_available", "current_version": "v1.0.0", "latest_version": "v1.2.3", "update_available": True})
monkeypatch.setattr(main.settings, "update_command", ["/opt/importarr/repo-upgrade.sh"])
scheduled = []
monkeypatch.setattr(main, "_schedule_update", scheduled.append)
response = main.update_service(expected_tag="v1.2.3")
assert scheduled == [["/opt/importarr/repo-upgrade.sh", "v1.2.3"]]
assert response["status"] == "update_scheduled"
def test_update_endpoint_requires_expected_tag(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")
monkeypatch.setattr(main, "check_update_available", lambda: pytest.fail("release lookup must not run"))
response = TestClient(main.app).post(
"/api/control/update",
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "expected_tag"]
def test_update_rejects_unexpected_latest_release(tmp_path, monkeypatch):
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
import importarr.main as main
from fastapi import HTTPException
monkeypatch.setattr(main, "check_update_available", lambda: {"latest_version": "v1.2.4", "update_available": True})
scheduled = []
monkeypatch.setattr(main, "_schedule_update", scheduled.append)
with pytest.raises(HTTPException) as exc_info:
main.update_service(expected_tag="v1.2.3")
assert exc_info.value.status_code == 409
assert scheduled == []