Retire review deploy and add live Actions
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
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 }}
|
||||||
|
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 -B main origin/main;
|
||||||
|
exec /bin/sh deploy/repo-upgrade.sh'
|
||||||
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
+10
-13
@@ -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,10 +16,8 @@ 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 [ "$#" -ne 0 ]; then
|
||||||
|
echo "Usage: $0" >&2
|
||||||
if [ -z "$RELEASE_TAG" ]; then
|
|
||||||
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
|
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -42,20 +40,19 @@ if [ -n "$(git status --porcelain)" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git fetch --prune --tags origin
|
git fetch --prune origin main
|
||||||
if ! git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG" >/dev/null; then
|
git checkout -B main origin/main
|
||||||
echo "Release tag not found: $RELEASE_TAG" >&2
|
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
git checkout --detach "$RELEASE_TAG"
|
|
||||||
test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_TAG"
|
|
||||||
"$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir "$REPO_DIR"
|
"$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir "$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"
|
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)"
|
||||||
BUILD_ENV_TMP="$PREFIX/build.env.tmp.$$"
|
BUILD_ENV_TMP="$PREFIX/build.env.tmp.$$"
|
||||||
trap 'rm -f "$BUILD_ENV_TMP"' EXIT HUP INT TERM
|
trap 'rm -f "$BUILD_ENV_TMP"' EXIT HUP INT TERM
|
||||||
cat > "$BUILD_ENV_TMP" <<EOF
|
cat > "$BUILD_ENV_TMP" <<EOF
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -46,4 +46,4 @@ 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"
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -36,18 +36,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></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>
|
||||||
|
|||||||
@@ -35,13 +35,6 @@ 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]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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,85 +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:
|
|
||||||
result = subprocess.run(
|
|
||||||
["systemd-run", "--unit=importarr-update", "--collect", "--no-block", "--on-active=2s", "--", *command],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(status_code=500, detail=f"update command failed to schedule: {result.stderr[-1000:]}")
|
|
||||||
except subprocess.TimeoutExpired as exc:
|
|
||||||
raise HTTPException(status_code=504, detail="update command timed out while scheduling") from exc
|
|
||||||
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")
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
|
|||||||
@@ -18,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']}",
|
||||||
@@ -57,3 +57,17 @@ def test_repo_upgrade_forces_fresh_install_without_skipping_dependencies():
|
|||||||
|
|
||||||
assert "pip\" install --upgrade --force-reinstall --no-cache-dir" in script
|
assert "pip\" install --upgrade --force-reinstall --no-cache-dir" in script
|
||||||
assert "--no-deps" not in script
|
assert "--no-deps" not 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
|
||||||
|
|||||||
@@ -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
-75
@@ -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,76 +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_is_scheduled_outside_service_cgroup(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
|
||||||
import importarr.main as main
|
|
||||||
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_run(command, **kwargs):
|
|
||||||
calls.append((command, kwargs))
|
|
||||||
return main.subprocess.CompletedProcess(command, 0, stdout="Running as unit", stderr="")
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
|
||||||
main._schedule_update(["/bin/sh", "/opt/importarr/repo-upgrade.sh", "v1.2.3"])
|
|
||||||
|
|
||||||
assert calls[0][0] == [
|
|
||||||
"systemd-run",
|
|
||||||
"--unit=importarr-update",
|
|
||||||
"--collect",
|
|
||||||
"--no-block",
|
|
||||||
"--on-active=2s",
|
|
||||||
"--",
|
|
||||||
"/bin/sh",
|
|
||||||
"/opt/importarr/repo-upgrade.sh",
|
|
||||||
"v1.2.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
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 == []
|
|
||||||
|
|||||||
Reference in New Issue
Block a user