Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3929fae4 |
@@ -1,15 +0,0 @@
|
|||||||
.git
|
|
||||||
.venv
|
|
||||||
.review-data
|
|
||||||
deploy/importarr.review.env
|
|
||||||
.pytest_cache
|
|
||||||
.mypy_cache
|
|
||||||
.ruff_cache
|
|
||||||
.tox
|
|
||||||
**/__pycache__
|
|
||||||
**/*.pyc
|
|
||||||
**/*.pyo
|
|
||||||
*.egg-info
|
|
||||||
.coverage
|
|
||||||
htmlcov
|
|
||||||
frontend/node_modules
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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'"
|
|
||||||
@@ -6,4 +6,3 @@ __pycache__/
|
|||||||
*.db
|
*.db
|
||||||
*.partial
|
*.partial
|
||||||
AGENTS.local.md
|
AGENTS.local.md
|
||||||
frontend/node_modules/
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# Repository Instructions
|
|
||||||
|
|
||||||
## Deployment workflow
|
|
||||||
|
|
||||||
Pushes to `main` deploy through the bounded Gitea Actions workflow. Follow the
|
|
||||||
host-local operational runbook for credentials, live URLs, and verification;
|
|
||||||
never put tokens, SSH keys, or other secrets in this repository or logs.
|
|
||||||
+1
-9
@@ -1,17 +1,9 @@
|
|||||||
FROM node:22-alpine AS frontend
|
|
||||||
WORKDIR /build/frontend
|
|
||||||
COPY frontend/package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY frontend ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM python:3.12-slim AS runtime
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml README.md LICENSE ./
|
COPY pyproject.toml README.md LICENSE ./
|
||||||
COPY importarr ./importarr
|
COPY importarr ./importarr
|
||||||
COPY --from=frontend /build/importarr/static ./importarr/static
|
ARG IMPORTARR_VERSION=0.1.0
|
||||||
ARG IMPORTARR_VERSION=0.1.1
|
|
||||||
ARG IMPORTARR_BUILD_DATE=unknown
|
ARG IMPORTARR_BUILD_DATE=unknown
|
||||||
ARG IMPORTARR_GIT_SHA=unknown
|
ARG IMPORTARR_GIT_SHA=unknown
|
||||||
ENV IMPORTARR_VERSION=$IMPORTARR_VERSION \
|
ENV IMPORTARR_VERSION=$IMPORTARR_VERSION \
|
||||||
|
|||||||
@@ -41,23 +41,11 @@ For a machine that should stay current with the repository, use the installed re
|
|||||||
sudo -n sh /opt/importarr/repo-upgrade.sh
|
sudo -n sh /opt/importarr/repo-upgrade.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The helper 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`.
|
The helper refuses to run when the checkout has uncommitted changes, then performs `git pull --ff-only`, reinstalls the package from the repo, restarts `importarr.service`, and prints service status. Use it after changes have been committed and pushed to `main`.
|
||||||
|
|
||||||
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:
|
Installed deployments can expose the same operation through the authenticated API. `GET /api/control/update-check` queries the latest release from `IMPORTARR_UPDATE_RELEASE_URL` (default: this repository's Gitea latest-release API) and compares it with the running `IMPORTARR_VERSION`. `POST /api/control/update` performs the same check and only runs the update command when a newer release tag exists. Configure `IMPORTARR_UPDATE_COMMAND` when the default `sh deploy/repo-upgrade.sh` is not correct for the service working directory, and configure `IMPORTARR_RESTART_COMMAND` when the default `systemctl restart importarr.service` needs a wrapper such as sudo.
|
||||||
|
|
||||||
```sh
|
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.
|
||||||
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
|
||||||
|
|
||||||
@@ -65,7 +53,7 @@ Release-worthy changes should be committed and pushed to `main`; tags remain opt
|
|||||||
2. Set its completed folder to the same path mounted as `IMPORTARR_DOWNLOAD_ROOT`.
|
2. Set its completed folder to the same path mounted as `IMPORTARR_DOWNLOAD_ROOT`.
|
||||||
3. Set `IMPORTARR_SAB_URL` and `IMPORTARR_SAB_API_KEY_FILE` or `IMPORTARR_SAB_API_KEY`.
|
3. Set `IMPORTARR_SAB_URL` and `IMPORTARR_SAB_API_KEY_FILE` or `IMPORTARR_SAB_API_KEY`.
|
||||||
4. Mount/configure `IMPORTARR_MOVIES_ROOT` and `IMPORTARR_TV_ROOT` read/write.
|
4. Mount/configure `IMPORTARR_MOVIES_ROOT` and `IMPORTARR_TV_ROOT` read/write.
|
||||||
5. Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` for every non-loopback bind. Importarr fails closed on write/control endpoints when a non-loopback service has no token; tokenless local development remains available on `127.0.0.1`, `::1`, or `localhost`.
|
5. Set `IMPORTARR_AUTH_TOKEN` unless write endpoints are protected by a reverse proxy.
|
||||||
6. Check `GET /health`, then inspect `/api/preview` before running imports.
|
6. Check `GET /health`, then inspect `/api/preview` before running imports.
|
||||||
|
|
||||||
## Safety model
|
## Safety model
|
||||||
@@ -90,6 +78,8 @@ Release-worthy changes should be committed and pushed to `main`; tags remain opt
|
|||||||
- `POST /api/control/stop`
|
- `POST /api/control/stop`
|
||||||
- `POST /api/control/cancel-current`
|
- `POST /api/control/cancel-current`
|
||||||
- `POST /api/control/restart`
|
- `POST /api/control/restart`
|
||||||
|
- `GET /api/control/update-check`
|
||||||
|
- `POST /api/control/update`
|
||||||
- `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
|
- `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
|
||||||
- `POST /api/import/run-now`
|
- `POST /api/import/run-now`
|
||||||
|
|
||||||
@@ -101,18 +91,10 @@ Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` to require `Authorizat
|
|||||||
python3.12 -m venv .venv
|
python3.12 -m venv .venv
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
pip install -e '.[test]'
|
pip install -e '.[test]'
|
||||||
npm --prefix frontend install
|
|
||||||
npm --prefix frontend run build
|
|
||||||
pytest
|
pytest
|
||||||
uvicorn importarr.main:app --reload
|
uvicorn importarr.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
The web UI is a Vite React application styled with Tailwind CSS. Its local
|
|
||||||
shadcn-style component primitives use Radix UI for dialogs and composition, and
|
|
||||||
lucide-react for icons. Run `npm --prefix frontend run dev` for Vite's development
|
|
||||||
server (it proxies API calls to port 8765), or build before running FastAPI so the
|
|
||||||
production assets are written to `importarr/static`.
|
|
||||||
|
|
||||||
## 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,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
importarr:
|
importarr:
|
||||||
image: ghcr.io/OWNER/importarr:0.1.1
|
image: ghcr.io/OWNER/importarr:0.1.0
|
||||||
env_file: importarr.env
|
env_file: importarr.env
|
||||||
ports:
|
ports:
|
||||||
- "8765:8765"
|
- "8765:8765"
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ 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
|
||||||
|
|||||||
+5
-38
@@ -13,26 +13,9 @@ if [ -f "$ENV_FILE" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
PREFIX=${IMPORTARR_PREFIX:-/opt/importarr}
|
PREFIX=${IMPORTARR_PREFIX:-/opt/importarr}
|
||||||
REPO_DIR=${IMPORTARR_REPO_DIR:-$PREFIX/repo}
|
REPO_DIR=${IMPORTARR_REPO_DIR:-$(pwd)}
|
||||||
SERVICE=${IMPORTARR_SERVICE:-importarr.service}
|
SERVICE=${IMPORTARR_SERVICE:-importarr.service}
|
||||||
VENV=${IMPORTARR_VENV:-$PREFIX/venv}
|
VENV=${IMPORTARR_VENV:-$PREFIX/venv}
|
||||||
if [ "$#" -gt 2 ]; then
|
|
||||||
echo "Usage: $0 [wheel [revision]]" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
WHEEL=${1:-}
|
|
||||||
REVISION=${2:-}
|
|
||||||
if [ -n "$WHEEL" ] && [ ! -f "$WHEEL" ]; then
|
|
||||||
echo "Built wheel not found: $WHEEL" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
LOCK_FILE=${IMPORTARR_UPGRADE_LOCK_FILE:-$PREFIX/repo-upgrade.lock}
|
|
||||||
exec 9>"$LOCK_FILE"
|
|
||||||
if ! flock -n 9; then
|
|
||||||
echo "Refusing to upgrade: another Importarr upgrade is already running." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||||
echo "Importarr repo not found at $REPO_DIR" >&2
|
echo "Importarr repo not found at $REPO_DIR" >&2
|
||||||
@@ -46,33 +29,17 @@ if [ -n "$(git status --porcelain)" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$REVISION" ]; then
|
git fetch --prune origin
|
||||||
test "$(git rev-parse HEAD)" = "$(git rev-parse "$REVISION^{commit}")"
|
git pull --ff-only
|
||||||
else
|
"$VENV/bin/pip" install --upgrade "$REPO_DIR"
|
||||||
git fetch --prune origin main
|
|
||||||
git checkout -B main origin/main
|
|
||||||
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
|
|
||||||
fi
|
|
||||||
"$VENV/bin/pip" install --upgrade --force-reinstall --no-cache-dir "${WHEEL:-$REPO_DIR}"
|
|
||||||
if [ -f "$ENV_FILE" ]; then
|
|
||||||
sed -i '/^[[:space:]]*IMPORTARR_UPDATE_COMMAND=/d' "$ENV_FILE"
|
|
||||||
fi
|
|
||||||
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
|
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
|
||||||
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" "$PREFIX/repo-upgrade.sh"
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || printf development)"
|
GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || printf development)"
|
||||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
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_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"
|
||||||
|
|||||||
@@ -11,36 +11,25 @@ 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 --force-reinstall --no-cache-dir "$REPO_DIR"
|
/opt/importarr/venv/bin/pip install --upgrade "$REPO_DIR"
|
||||||
if [ ! -f /etc/importarr/importarr.env ]; then
|
if [ ! -f /etc/importarr/importarr.env ]; then
|
||||||
install -m 0600 "$REPO_DIR/deploy/importarr.env.example" /etc/importarr/importarr.env
|
install -m 0600 "$REPO_DIR/deploy/importarr.env.example" /etc/importarr/importarr.env
|
||||||
echo "Created /etc/importarr/importarr.env; edit it before starting the service."
|
echo "Created /etc/importarr/importarr.env; edit it before starting the service."
|
||||||
fi
|
fi
|
||||||
|
if ! grep -q '^IMPORTARR_REPO_DIR=' /etc/importarr/importarr.env; then
|
||||||
|
printf '\nIMPORTARR_REPO_DIR=%s\n' "$REPO_DIR" >> /etc/importarr/importarr.env
|
||||||
|
fi
|
||||||
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
|
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
|
||||||
install -m 0644 "$REPO_DIR/deploy/manual-media-import.service" /etc/systemd/system/manual-media-import.service
|
install -m 0644 "$REPO_DIR/deploy/manual-media-import.service" /etc/systemd/system/manual-media-import.service
|
||||||
install -m 0644 "$REPO_DIR/deploy/manual-media-import.timer" /etc/systemd/system/manual-media-import.timer
|
install -m 0644 "$REPO_DIR/deploy/manual-media-import.timer" /etc/systemd/system/manual-media-import.timer
|
||||||
install -m 0644 "$REPO_DIR/deploy/manual-media-import-failure.service" /etc/systemd/system/manual-media-import-failure.service
|
install -m 0644 "$REPO_DIR/deploy/manual-media-import-failure.service" /etc/systemd/system/manual-media-import-failure.service
|
||||||
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" /opt/importarr/repo-upgrade.sh
|
install -m 0755 "$REPO_DIR/deploy/repo-upgrade.sh" /opt/importarr/repo-upgrade.sh
|
||||||
if [ "$REPO_DIR" != /opt/importarr/repo ]; then
|
|
||||||
if [ -e /opt/importarr/repo ] && [ ! -L /opt/importarr/repo ]; then
|
|
||||||
echo "Refusing to replace existing /opt/importarr/repo; move it or set IMPORTARR_REPO_DIR explicitly." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ln -sfn "$REPO_DIR" /opt/importarr/repo
|
|
||||||
fi
|
|
||||||
GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short=12 HEAD 2>/dev/null || printf development)"
|
GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short=12 HEAD 2>/dev/null || printf development)"
|
||||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
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_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
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="theme-color" content="#252B42" />
|
|
||||||
<title>Importarr</title>
|
|
||||||
</head>
|
|
||||||
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
|
|
||||||
</html>
|
|
||||||
Generated
-5604
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "importarr-ui",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.3",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.468.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"tailwind-merge": "^2.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
|
||||||
"@testing-library/react": "^16.1.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"vite": "^6.0.5",
|
|
||||||
"vitest": "^2.1.8",
|
|
||||||
"jsdom": "^25.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva } from "class-variance-authority";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { cn } from "../lib/utils";
|
|
||||||
|
|
||||||
const buttonVariants = cva("inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary disabled:pointer-events-none disabled:opacity-50", {
|
|
||||||
variants: { variant: { default: "bg-primary text-white hover:bg-secondary", secondary: "bg-secondary text-white hover:bg-primary", outline: "border bg-card hover:bg-input", ghost: "hover:bg-input", destructive: "bg-primary text-white hover:bg-secondary" }, size: { default: "h-9 px-4", icon: "h-9 w-9 p-0", sm: "h-8 px-3" } },
|
|
||||||
defaultVariants: { variant: "default", size: "default" }
|
|
||||||
});
|
|
||||||
export function Button({ className, variant, size, asChild = false, ...props }) { const Comp = asChild ? Slot : "button"; return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />; }
|
|
||||||
export function Input({ className, ...props }) { return <input className={cn("flex h-9 w-full rounded-md border bg-input px-3 py-1 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-secondary", className)} {...props} />; }
|
|
||||||
export function Card({ className, ...props }) { return <section className={cn("rounded-lg border bg-card shadow-sm", className)} {...props} />; }
|
|
||||||
export function CardHeader({ className, ...props }) { return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />; }
|
|
||||||
export function CardTitle({ className, ...props }) { return <h2 className={cn("text-lg font-semibold", className)} {...props} />; }
|
|
||||||
export function CardContent({ className, ...props }) { return <div className={cn("p-6 pt-0", className)} {...props} />; }
|
|
||||||
export const Dialog = DialogPrimitive.Root;
|
|
||||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
|
||||||
export const DialogClose = DialogPrimitive.Close;
|
|
||||||
export function DialogContent({ className, children, ...props }) { return <DialogPrimitive.Portal><DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" /><DialogPrimitive.Content className={cn("fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border bg-card p-6 shadow-xl", className)} {...props}>{children}<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground hover:text-foreground" aria-label="Close"><X className="h-4 w-4" /></DialogPrimitive.Close></DialogPrimitive.Content></DialogPrimitive.Portal>; }
|
|
||||||
export function DialogHeader({ className, ...props }) { return <div className={cn("mb-4 space-y-1.5", className)} {...props} />; }
|
|
||||||
export function DialogTitle({ className, ...props }) { return <DialogPrimitive.Title className={cn("text-lg font-semibold", className)} {...props} />; }
|
|
||||||
export function Badge({ className, ...props }) { return <span className={cn("inline-flex rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-white", className)} {...props} />; }
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--background: #F8FAFC;
|
|
||||||
--card: #FFFFFF;
|
|
||||||
--input: #FFFFFF;
|
|
||||||
--primary: #4A43EC;
|
|
||||||
--secondary: #7171FF;
|
|
||||||
--accent: #2AD1ED;
|
|
||||||
--foreground: #1A1D2E;
|
|
||||||
--muted-foreground: #64748B;
|
|
||||||
--border: #E2E8F0;
|
|
||||||
}
|
|
||||||
.dark {
|
|
||||||
color-scheme: dark;
|
|
||||||
--background: #252B42;
|
|
||||||
--card: #303753;
|
|
||||||
--input: #23283B;
|
|
||||||
--primary: #4A43EC;
|
|
||||||
--secondary: #7171FF;
|
|
||||||
--accent: #42ECF5;
|
|
||||||
--foreground: #FFFFFF;
|
|
||||||
--muted-foreground: #8B95B7;
|
|
||||||
--border: #3D4668;
|
|
||||||
}
|
|
||||||
* { @apply border-border; }
|
|
||||||
body { @apply m-0 min-w-0 bg-background text-foreground antialiased; }
|
|
||||||
button, input { font: inherit; }
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { clsx } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
export const cn = (...inputs) => twMerge(clsx(inputs));
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react";
|
|
||||||
import "./globals.css";
|
|
||||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui";
|
|
||||||
|
|
||||||
const groups = ["ready", "importing", "failed", "manual_batch", "completed", "ignored_category", "sab_processing"];
|
|
||||||
const labels = { sab_processing: "SAB processing", ready: "Ready", importing: "Importing", failed: "Failed", ignored_category: "Ignored category", manual_batch: "Manual batch", completed: "Completed" };
|
|
||||||
const bytes = value => { let n=Number(value||0), i=0; const units=["B","KB","MB","GB","TB"]; if(!n)return "size unknown"; while(n>=1024&&i<4){n/=1024;i++} return `${n.toFixed(n>=10||!i?0:1)} ${units[i]}`; };
|
|
||||||
const duration = value => { const s=Math.max(0,Math.floor(Number(value||0))); return s>=60?`${Math.floor(s/60)}m ${String(s%60).padStart(2,"0")}s`:`${s}s`; };
|
|
||||||
|
|
||||||
export async function request(url, requestOptions={}, retryAuth=true) {
|
|
||||||
const { auth=false, ...options }=requestOptions;
|
|
||||||
const protectedRequest=auth||(options.method&&options.method!=="GET"), token=protectedRequest?sessionStorage.getItem("importarr-auth-token"):null;
|
|
||||||
const headers={...options.headers,...(token?{Authorization:`Bearer ${token}`}:{})};
|
|
||||||
const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...headers},body:JSON.stringify(options.body)} : {...options,headers});
|
|
||||||
if(response.status===401&&protectedRequest&&retryAuth){const entered=prompt("Importarr write access token");if(entered){sessionStorage.setItem("importarr-auth-token",entered);return request(url,requestOptions,false)}}
|
|
||||||
if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); throw new Error(typeof error.detail==="string"?error.detail:"Request failed"); }
|
|
||||||
return response.status===204?null:response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pollDelay = failures => failures ? Math.min(5000,1000*2**(failures-1)) : 300;
|
|
||||||
|
|
||||||
function Modal({ trigger, title, children }) { return <Dialog><DialogTrigger asChild>{trigger}</DialogTrigger><DialogContent><DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>{children}</DialogContent></Dialog>; }
|
|
||||||
function 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 }) {
|
|
||||||
const [result,setResult]=useState({});
|
|
||||||
const [urls,setUrls]=useState({sab_url:"",radarr_url:"",sonarr_url:""});
|
|
||||||
useEffect(()=>setUrls({sab_url:status?.sab_url||"",radarr_url:status?.radarr_url||"",sonarr_url:status?.sonarr_url||""}),[status?.sab_url,status?.radarr_url,status?.sonarr_url]);
|
|
||||||
const submit=async e=>{ e.preventDefault(); try { await request("/api/settings",{method:"POST",body:Object.fromEntries(new FormData(e.currentTarget))}); e.currentTarget.reset(); await refresh(); } catch(error){ alert(error.message); } };
|
|
||||||
const test=async (service,form)=>{ const prefix=service==="sabnzbd"?"sab":service; setResult(r=>({...r,[service]:"Testing…"})); try { const data=await request("/api/settings/test-connection",{method:"POST",body:{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}}); setResult(r=>({...r,[service]:data.message})); } catch(error){setResult(r=>({...r,[service]:error.message}))} };
|
|
||||||
return <><Modal title="Settings" trigger={<Button variant="ghost" className="w-full justify-start"><Settings/> Settings</Button>}><form onSubmit={submit} className="grid gap-6">{[["sab","sabnzbd","SABnzbd"],["radarr","radarr","Radarr"],["sonarr","sonarr","Sonarr"]].map(([prefix,service,label])=><fieldset className="grid gap-3 rounded-md border p-4" key={service}><legend className="px-2 font-semibold">{label}</legend><Field label={`${label} URL`} name={`${prefix}_url`} type="url" required={prefix==="sab"} value={urls[`${prefix}_url`]} onChange={e=>setUrls(current=>({...current,[e.target.name]:e.target.value}))}/><Field label="API token" name={`${prefix}_api_key`} type="password" autoComplete="off" placeholder={status?.[`${prefix}_api_key_configured`]?"Configured; enter replacement":"API token"}/><div className="flex items-center gap-3"><Button type="button" variant="outline" onClick={e=>test(service,e.currentTarget.form)}>Test connection</Button><span className="text-sm text-muted-foreground">{result[service]}</span></div></fieldset>)}<p className="text-sm text-muted-foreground">Blank token fields clear stored tokens.</p><Button>Save settings</Button></form></Modal><Button variant="ghost" className="w-full justify-start" onClick={async()=>{try{await request("/api/import/run-now",{method:"POST",body:{force:true}});await refresh()}catch(error){alert(error.message)}}}><Zap/> Force run now</Button></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(){
|
|
||||||
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 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(()=>{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 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;
|
|
||||||
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><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">
|
|
||||||
<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 gap-3"><History className="text-accent"/><div><CardTitle>Queue and history</CardTitle><p className="text-sm text-muted-foreground">Grouped by processing state</p></div></CardHeader><CardContent className="grid gap-6">{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?<section key={group}><h3 className="mb-3 flex items-center gap-2 font-semibold">{labels[group]} <Badge>{items.length}</Badge></h3><div className="grid gap-2">{items.map(j=><article className="grid gap-3 rounded-md border bg-input p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" key={j.id}><div className="min-w-0"><strong className="block break-words">{j.name}</strong><p className="break-all text-xs text-muted-foreground">{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}</p><div className="mt-2 flex flex-wrap items-center gap-2"><Badge>{j.state}</Badge><span className="text-xs text-muted-foreground">{j.reason}</span></div></div><div className="flex gap-2">{j.can_run_now&&<Button size="icon" title="Run now" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"run-now"})}><Play/></Button>}{j.can_retry&&<Button size="icon" variant="outline" title="Retry" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"retry"})}><RefreshCw/></Button>}{j.can_ignore&&<Button size="icon" variant="outline" title="Ignore" onClick={()=>confirm("Ignore this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"ignore"})}><XCircle/></Button>}{j.can_remove&&<Button size="icon" variant="outline" title="Remove" onClick={()=>confirm("Remove this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"remove"})}><Trash2/></Button>}</div></article>)}</div></section>:null}):<p className="text-muted-foreground">No queue items.</p>}</CardContent></Card>
|
|
||||||
</main></div>
|
|
||||||
}
|
|
||||||
const root=document.getElementById("root");
|
|
||||||
if(root) createRoot(root).render(<React.StrictMode><App/></React.StrictMode>);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { BuildSummary, pollDelay, request, SettingsDialog } from "./main";
|
|
||||||
|
|
||||||
describe("Importarr UI behavior", () => {
|
|
||||||
beforeEach(() => { sessionStorage.clear(); vi.stubGlobal("fetch", vi.fn()); });
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
|
||||||
|
|
||||||
it("hydrates settings URLs when status arrives asynchronously", async () => {
|
|
||||||
const { rerender } = render(<SettingsDialog status={null} refresh={vi.fn()} />);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
|
|
||||||
expect(screen.getByLabelText("SABnzbd URL")).toHaveValue("");
|
|
||||||
|
|
||||||
rerender(<SettingsDialog status={{ sab_url: "http://sab", radarr_url: "http://radarr", sonarr_url: "http://sonarr" }} refresh={vi.fn()} />);
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByLabelText("SABnzbd URL")).toHaveValue("http://sab"));
|
|
||||||
expect(screen.getByLabelText("Radarr URL")).toHaveValue("http://radarr");
|
|
||||||
expect(screen.getByLabelText("Sonarr URL")).toHaveValue("http://sonarr");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("posts a forced global run", async () => {
|
|
||||||
fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
|
||||||
render(<SettingsDialog status={null} refresh={vi.fn()} />);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Force run now" }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/import/run-now", expect.objectContaining({ method: "POST", body: JSON.stringify({ force: true }) })));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prompts for write auth and retries with the entered token", async () => {
|
|
||||||
fetch.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ detail: "auth required" }) }).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ok: true }) });
|
|
||||||
vi.stubGlobal("prompt", vi.fn(() => "browser-token"));
|
|
||||||
await request("/api/queue-items/1/action", { method: "POST", body: { action: "run-now" } });
|
|
||||||
expect(fetch).toHaveBeenLastCalledWith("/api/queue-items/1/action", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("backs polling off after failures and immediately recovers its fast interval", () => {
|
|
||||||
expect([pollDelay(0),pollDelay(1),pollDelay(2),pollDelay(4)]).toEqual([300,1000,2000,5000]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the build commit and date in the header summary", () => {
|
|
||||||
render(<BuildSummary build={{ git_sha: "abc123def456", build_date: "2026-08-11T12:34:56+02:00" }} />);
|
|
||||||
expect(screen.getByText("Build abc123def456 · 2026-08-11T12:34:56+02:00")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
background: "var(--background)", foreground: "var(--foreground)",
|
|
||||||
card: "var(--card)", input: "var(--input)", primary: "var(--primary)",
|
|
||||||
secondary: "var(--secondary)", accent: "var(--accent)",
|
|
||||||
muted: { foreground: "var(--muted-foreground)" }, border: "var(--border)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
base: "/static/",
|
|
||||||
server: { proxy: { "/api": "http://127.0.0.1:8765", "/health": "http://127.0.0.1:8765" } },
|
|
||||||
build: {
|
|
||||||
outDir: resolve(import.meta.dirname, "../importarr/static"),
|
|
||||||
emptyOutDir: true,
|
|
||||||
rollupOptions: { output: { entryFileNames: "assets/app.js", assetFileNames: "assets/app.[ext]" } }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.3"
|
__version__ = "0.1.0"
|
||||||
|
|||||||
+8
-6
@@ -22,9 +22,10 @@ class Settings(BaseModel):
|
|||||||
sonarr_url: str | None = None
|
sonarr_url: str | None = None
|
||||||
sonarr_api_key: str | None = None
|
sonarr_api_key: str | None = None
|
||||||
auth_token: str | None = None
|
auth_token: str | None = None
|
||||||
start_command: list[str] = Field(default_factory=lambda: ["systemctl", "start", "manual-media-import.service"])
|
restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "importarr.service"])
|
||||||
stop_command: list[str] = Field(default_factory=lambda: ["systemctl", "stop", "manual-media-import.service"])
|
update_command: list[str] = Field(default_factory=lambda: ["sh", "deploy/repo-upgrade.sh"])
|
||||||
restart_command: list[str] = Field(default_factory=lambda: ["systemctl", "restart", "manual-media-import.service"])
|
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
|
||||||
@@ -47,9 +48,10 @@ class Settings(BaseModel):
|
|||||||
sonarr_url=os.getenv("IMPORTARR_SONARR_URL"),
|
sonarr_url=os.getenv("IMPORTARR_SONARR_URL"),
|
||||||
sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"),
|
sonarr_api_key=_env_secret("IMPORTARR_SONARR_API_KEY"),
|
||||||
auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"),
|
auth_token=_env_secret("IMPORTARR_AUTH_TOKEN"),
|
||||||
start_command=_env_command("IMPORTARR_START_COMMAND", ["systemctl", "start", "manual-media-import.service"]),
|
restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "importarr.service"]),
|
||||||
stop_command=_env_command("IMPORTARR_STOP_COMMAND", ["systemctl", "stop", "manual-media-import.service"]),
|
update_command=_env_command("IMPORTARR_UPDATE_COMMAND", ["sh", "deploy/repo-upgrade.sh"]),
|
||||||
restart_command=_env_command("IMPORTARR_RESTART_COMMAND", ["systemctl", "restart", "manual-media-import.service"]),
|
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")),
|
||||||
|
|||||||
@@ -27,14 +27,10 @@ class Importer:
|
|||||||
target_root = self.tv_root if _looks_like_tv(source) else self.movies_root
|
target_root = self.tv_root if _looks_like_tv(source) else self.movies_root
|
||||||
return _unique_path(target_root / source.name)
|
return _unique_path(target_root / source.name)
|
||||||
|
|
||||||
def import_file(self, source: Path, should_cancel: Callable[[], bool] | None = None, on_progress: Callable[[int, int], None] | None = None) -> ImportResult:
|
def import_file(self, source: Path, should_cancel: Callable[[], bool] | None = None) -> ImportResult:
|
||||||
target = self.target_for(source)
|
target = self.target_for(source)
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
partial = target.with_name(target.name + ".partial")
|
partial = target.with_name(target.name + ".partial")
|
||||||
total = source.stat().st_size
|
|
||||||
copied = 0
|
|
||||||
if on_progress:
|
|
||||||
on_progress(copied, total)
|
|
||||||
try:
|
try:
|
||||||
with source.open("rb") as src, partial.open("wb") as dst:
|
with source.open("rb") as src, partial.open("wb") as dst:
|
||||||
while True:
|
while True:
|
||||||
@@ -44,15 +40,12 @@ class Importer:
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
dst.write(chunk)
|
dst.write(chunk)
|
||||||
copied += len(chunk)
|
|
||||||
if on_progress:
|
|
||||||
on_progress(copied, total)
|
|
||||||
dst.flush()
|
dst.flush()
|
||||||
os.fsync(dst.fileno())
|
os.fsync(dst.fileno())
|
||||||
except ImportCancelled:
|
except ImportCancelled:
|
||||||
partial.unlink(missing_ok=True)
|
partial.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
if partial.stat().st_size != total:
|
if partial.stat().st_size != source.stat().st_size:
|
||||||
raise IOError("partial copy size mismatch")
|
raise IOError("partial copy size mismatch")
|
||||||
partial.rename(target)
|
partial.rename(target)
|
||||||
source.unlink()
|
source.unlink()
|
||||||
|
|||||||
+127
-158
@@ -1,18 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .build_info import build_info
|
from .build_info import build_info
|
||||||
@@ -25,15 +22,9 @@ from .state import State
|
|||||||
|
|
||||||
settings = Settings.from_env()
|
settings = Settings.from_env()
|
||||||
state = State(settings.state_path)
|
state = State(settings.state_path)
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||||
app = FastAPI(title="Importarr")
|
app = FastAPI(title="Importarr")
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
|
||||||
WORKER_ID = f"importarr-{os.getpid()}"
|
|
||||||
WORKER_SHUTDOWN_TIMEOUT_SECONDS = 5.0
|
|
||||||
_worker_thread: threading.Thread | None = None
|
|
||||||
_worker_stop = threading.Event()
|
|
||||||
MAX_RETRY_ATTEMPTS = 3
|
|
||||||
RETRY_DELAY_SECONDS = 60
|
|
||||||
|
|
||||||
|
|
||||||
class ManualBatchCreate(BaseModel):
|
class ManualBatchCreate(BaseModel):
|
||||||
@@ -60,6 +51,14 @@ 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
|
||||||
@@ -87,9 +86,7 @@ load_ui_settings()
|
|||||||
|
|
||||||
def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
||||||
if not settings.auth_token:
|
if not settings.auth_token:
|
||||||
if settings.bind_host in {"127.0.0.1", "::1", "localhost"}:
|
|
||||||
return
|
return
|
||||||
raise HTTPException(status_code=503, detail="write endpoints require IMPORTARR_AUTH_TOKEN on a non-loopback bind")
|
|
||||||
if authorization != f"Bearer {settings.auth_token}":
|
if authorization != f"Bearer {settings.auth_token}":
|
||||||
raise HTTPException(status_code=401, detail="write endpoint requires bearer token")
|
raise HTTPException(status_code=401, detail="write endpoint requires bearer token")
|
||||||
|
|
||||||
@@ -99,19 +96,15 @@ def health() -> dict[str, str]:
|
|||||||
return {"status": "ok", "name": "Importarr", "version": build_info()["version"]}
|
return {"status": "ok", "name": "Importarr", "version": build_info()["version"]}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=FileResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index() -> FileResponse:
|
def index(request: Request) -> HTMLResponse:
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
return templates.TemplateResponse(request, "index.html", {"status": status(), "batches": state.list_manual_batches()})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def status() -> dict[str, object]:
|
def status() -> dict[str, object]:
|
||||||
history = state.list_history()
|
history = state.list_history()
|
||||||
control = control_status()
|
control = control_status()
|
||||||
queue_items = state.list_queue_items(active_only=False)
|
|
||||||
queue_counts: dict[str, int] = {}
|
|
||||||
for row in queue_items:
|
|
||||||
queue_counts[row["state"]] = queue_counts.get(row["state"], 0) + 1
|
|
||||||
return {
|
return {
|
||||||
"app": "Importarr",
|
"app": "Importarr",
|
||||||
"build": build_info(),
|
"build": build_info(),
|
||||||
@@ -130,8 +123,6 @@ def status() -> dict[str, object]:
|
|||||||
"manual_batches": len(state.list_manual_batches(active_only=True)),
|
"manual_batches": len(state.list_manual_batches(active_only=True)),
|
||||||
"imported_total": sum(1 for row in history if row["status"] == "imported"),
|
"imported_total": sum(1 for row in history if row["status"] == "imported"),
|
||||||
"failed_total": sum(1 for row in history if row["status"] == "failed"),
|
"failed_total": sum(1 for row in history if row["status"] == "failed"),
|
||||||
"queue_total": len(queue_items),
|
|
||||||
"queue_counts": queue_counts,
|
|
||||||
"current": control["current"],
|
"current": control["current"],
|
||||||
"control": control,
|
"control": control,
|
||||||
}
|
}
|
||||||
@@ -198,7 +189,7 @@ async def test_connection(payload: ConnectionTestRequest, _: None = Depends(requ
|
|||||||
|
|
||||||
def control_status() -> dict[str, object]:
|
def control_status() -> dict[str, object]:
|
||||||
mode = state.get_app_state("queue_mode", "running") or "running"
|
mode = state.get_app_state("queue_mode", "running") or "running"
|
||||||
current = current_job_status()
|
current = state.get_app_state("current_job")
|
||||||
cancel_requested = (state.get_app_state("cancel_requested", "false") or "false") == "true"
|
cancel_requested = (state.get_app_state("cancel_requested", "false") or "false") == "true"
|
||||||
return {
|
return {
|
||||||
"queue_mode": mode,
|
"queue_mode": mode,
|
||||||
@@ -208,21 +199,6 @@ def control_status() -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def current_job_status() -> dict[str, object] | str:
|
|
||||||
raw = state.get_app_state("current_job") or ""
|
|
||||||
if not raw:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return raw
|
|
||||||
if isinstance(data, dict):
|
|
||||||
started_at = float(data.get("started_at") or time.time())
|
|
||||||
data["elapsed_seconds"] = max(0, int(time.time() - started_at))
|
|
||||||
return data
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def queue_accepting_new_jobs() -> bool:
|
def queue_accepting_new_jobs() -> bool:
|
||||||
return (state.get_app_state("queue_mode", "running") or "running") == "running"
|
return (state.get_app_state("queue_mode", "running") or "running") == "running"
|
||||||
|
|
||||||
@@ -238,50 +214,8 @@ def consume_cancel_request() -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def set_current_job(name: str | None, *, bytes_copied: int = 0, total_bytes: int = 0, started_at: float | None = None) -> float:
|
def set_current_job(name: str | None) -> None:
|
||||||
started = started_at or time.time()
|
state.set_app_state("current_job", name or "")
|
||||||
if not name:
|
|
||||||
state.set_app_state("current_job", "")
|
|
||||||
return started
|
|
||||||
percent = round((bytes_copied / total_bytes * 100), 2) if total_bytes else 0
|
|
||||||
state.set_app_state(
|
|
||||||
"current_job",
|
|
||||||
json.dumps({"file": name, "name": Path(name).name if name else name, "bytes_copied": bytes_copied, "total_bytes": total_bytes, "percent": percent, "started_at": started}),
|
|
||||||
)
|
|
||||||
return started
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_worker_running() -> None:
|
|
||||||
global _worker_thread
|
|
||||||
if _worker_thread and _worker_thread.is_alive():
|
|
||||||
return
|
|
||||||
_worker_stop.clear()
|
|
||||||
state.release_stale_claims()
|
|
||||||
_worker_thread = threading.Thread(target=_worker_loop, name="importarr-queue-worker", daemon=True)
|
|
||||||
_worker_thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def stop_worker(*, wait: bool = False) -> bool:
|
|
||||||
_worker_stop.set()
|
|
||||||
thread = _worker_thread
|
|
||||||
if wait and thread and thread.is_alive() and thread is not threading.current_thread():
|
|
||||||
thread.join(WORKER_SHUTDOWN_TIMEOUT_SECONDS)
|
|
||||||
return not thread or not thread.is_alive()
|
|
||||||
|
|
||||||
|
|
||||||
def _worker_loop() -> None:
|
|
||||||
importer = Importer(settings.movies_root, settings.tv_root)
|
|
||||||
while not _worker_stop.is_set():
|
|
||||||
try:
|
|
||||||
if queue_accepting_new_jobs():
|
|
||||||
sync_manual_queue()
|
|
||||||
item = state.claim_next_queue_item(WORKER_ID) if queue_accepting_new_jobs() else None
|
|
||||||
if item is not None:
|
|
||||||
_import_queue_item(item, importer, from_worker=True)
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
state.upsert_queue_item(source_type="system", source_id="queue-worker", name="Queue worker", state="failed", reason="worker loop error")
|
|
||||||
_worker_stop.wait(settings.poll_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/control/queue")
|
@app.post("/api/control/queue")
|
||||||
@@ -291,9 +225,6 @@ def set_queue_control(payload: QueueControlRequest, _: None = Depends(require_wr
|
|||||||
state.set_app_state("queue_mode", payload.mode)
|
state.set_app_state("queue_mode", payload.mode)
|
||||||
if payload.mode == "running":
|
if payload.mode == "running":
|
||||||
state.set_app_state("cancel_requested", "false")
|
state.set_app_state("cancel_requested", "false")
|
||||||
ensure_worker_running()
|
|
||||||
elif payload.mode == "stopped":
|
|
||||||
stop_worker()
|
|
||||||
return control_status()
|
return control_status()
|
||||||
|
|
||||||
|
|
||||||
@@ -301,8 +232,7 @@ def set_queue_control(payload: QueueControlRequest, _: None = Depends(require_wr
|
|||||||
def start_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
def start_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
state.set_app_state("queue_mode", "running")
|
state.set_app_state("queue_mode", "running")
|
||||||
state.set_app_state("cancel_requested", "false")
|
state.set_app_state("cancel_requested", "false")
|
||||||
ensure_worker_running()
|
return control_status()
|
||||||
return {"control": control_status(), "command_result": _run_control_command(settings.start_command)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/control/pause")
|
@app.post("/api/control/pause")
|
||||||
@@ -314,9 +244,7 @@ def pause_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
|||||||
@app.post("/api/control/stop")
|
@app.post("/api/control/stop")
|
||||||
def stop_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
def stop_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
state.set_app_state("queue_mode", "stopped")
|
state.set_app_state("queue_mode", "stopped")
|
||||||
state.set_app_state("cancel_requested", "true")
|
return control_status()
|
||||||
stop_worker()
|
|
||||||
return {"control": control_status(), "command_result": _run_control_command(settings.stop_command)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/control/cancel-current")
|
@app.post("/api/control/cancel-current")
|
||||||
@@ -330,6 +258,60 @@ def restart_service(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
|||||||
return _run_control_command(settings.restart_command)
|
return _run_control_command(settings.restart_command)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/control/update")
|
||||||
|
def update_service(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
update = check_update_available()
|
||||||
|
if not update["update_available"]:
|
||||||
|
return {**update, "command": settings.update_command, "stdout": "", "stderr": ""}
|
||||||
|
result = _run_control_command(settings.update_command)
|
||||||
|
return {**update, "command_result": result}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/control/update-check")
|
||||||
|
def update_check(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
return check_update_available()
|
||||||
|
|
||||||
|
|
||||||
|
def check_update_available() -> dict[str, object]:
|
||||||
|
current = build_info()["version"]
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=settings.update_check_timeout_seconds) as client:
|
||||||
|
response = client.get(settings.update_release_url, headers={"Accept": "application/json"})
|
||||||
|
response.raise_for_status()
|
||||||
|
release = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"release check failed: {exc.__class__.__name__}") from exc
|
||||||
|
|
||||||
|
latest = str(release.get("tag_name") or release.get("name") or "").strip()
|
||||||
|
if not latest:
|
||||||
|
raise HTTPException(status_code=502, detail="release check failed: latest release has no tag_name")
|
||||||
|
|
||||||
|
payload = UpdateCheckResponse(
|
||||||
|
status="update_available" if _is_newer_version(latest, current) else "current",
|
||||||
|
current_version=current,
|
||||||
|
latest_version=latest,
|
||||||
|
update_available=_is_newer_version(latest, current),
|
||||||
|
release_url=release.get("html_url"),
|
||||||
|
)
|
||||||
|
return payload.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_newer_version(candidate: str, current: str) -> bool:
|
||||||
|
candidate_version = _version_key(candidate)
|
||||||
|
current_version = _version_key(current)
|
||||||
|
if candidate_version is None or current_version is None:
|
||||||
|
return candidate.lstrip("vV") != current.lstrip("vV") and current in {"", "development"}
|
||||||
|
return candidate_version > current_version
|
||||||
|
|
||||||
|
|
||||||
|
def _version_key(value: str) -> tuple[int, ...] | None:
|
||||||
|
normalized = value.strip().lstrip("vV").split("-", 1)[0]
|
||||||
|
parts = normalized.split(".")
|
||||||
|
if not parts or any(not part.isdigit() for part in parts):
|
||||||
|
return None
|
||||||
|
return tuple(int(part) for part in parts)
|
||||||
|
|
||||||
|
|
||||||
def _run_control_command(command: list[str]) -> dict[str, object]:
|
def _run_control_command(command: list[str]) -> dict[str, object]:
|
||||||
if not command:
|
if not command:
|
||||||
raise HTTPException(status_code=500, detail="control command is not configured")
|
raise HTTPException(status_code=500, detail="control command is not configured")
|
||||||
@@ -394,25 +376,19 @@ def queue_item_action(item_id: int, payload: QueueItemActionRequest, _: None = D
|
|||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="queue item not found")
|
raise HTTPException(status_code=404, detail="queue item not found")
|
||||||
if payload.action == "retry":
|
if payload.action == "retry":
|
||||||
retry_state = "ready" if item["source_type"] in {"manual", "sab"} else "detected"
|
retry_state = "manual_batch" if item["source_type"] == "manual" else "ready"
|
||||||
if not state.transition_queue_item_if_unclaimed(item_id, {"failed", "skipped"}, retry_state, "retry requested"):
|
state.mark_queue_item(item["source_type"], item["source_id"], retry_state, "retry requested")
|
||||||
raise HTTPException(status_code=409, detail="queue item is currently being imported or changed")
|
|
||||||
elif payload.action == "run-now":
|
elif payload.action == "run-now":
|
||||||
claimed = state.claim_queue_item(item_id, WORKER_ID, {"ready", "failed", "retrying"})
|
imported = _import_queue_item(item, Importer(settings.movies_root, settings.tv_root))
|
||||||
if claimed is None:
|
|
||||||
raise HTTPException(status_code=409, detail="queue item is not available to run now")
|
|
||||||
imported = _import_queue_item(claimed, Importer(settings.movies_root, settings.tv_root), force=True, from_worker=True)
|
|
||||||
updated = state.get_queue_item(item_id)
|
updated = state.get_queue_item(item_id)
|
||||||
return {"status": "imported" if imported else "updated", "imported": imported, "item": serialize_queue_item(updated or item)}
|
return {"status": "imported" if imported else "updated", "imported": imported, "item": serialize_queue_item(updated or item)}
|
||||||
elif payload.action == "ignore":
|
elif payload.action == "ignore":
|
||||||
if not state.transition_queue_item_if_unclaimed(item_id, {str(item["state"])}, "skipped", "ignored by user"):
|
state.mark_queue_item(item["source_type"], item["source_id"], "skipped", "ignored by user")
|
||||||
raise HTTPException(status_code=409, detail="queue item is currently being imported or changed")
|
|
||||||
elif payload.action == "remove":
|
elif payload.action == "remove":
|
||||||
if not state.delete_queue_item_if_unclaimed(item_id):
|
state.delete_queue_item(item_id)
|
||||||
raise HTTPException(status_code=409, detail="queue item is currently being imported")
|
|
||||||
return {"status": "removed", "id": item_id}
|
return {"status": "removed", "id": item_id}
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="action must be retry, run-now, ignore, or remove")
|
raise HTTPException(status_code=400, detail="action must be retry, ignore, or remove")
|
||||||
updated = state.get_queue_item(item_id)
|
updated = state.get_queue_item(item_id)
|
||||||
return {"status": "updated", "item": serialize_queue_item(updated or item)}
|
return {"status": "updated", "item": serialize_queue_item(updated or item)}
|
||||||
|
|
||||||
@@ -440,6 +416,7 @@ async def sync_queue() -> None:
|
|||||||
state.upsert_queue_item(source_type="system", source_id="sab-sync", name="SABnzbd", state="failed", reason=exc.__class__.__name__)
|
state.upsert_queue_item(source_type="system", source_id="sab-sync", name="SABnzbd", state="failed", reason=exc.__class__.__name__)
|
||||||
return
|
return
|
||||||
slots = data.get("history", {}).get("slots", [])
|
slots = data.get("history", {}).get("slots", [])
|
||||||
|
state.delete_queue_items_by_state("sab", "ignored")
|
||||||
for item in slots:
|
for item in slots:
|
||||||
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, sab_storage_root=settings.sab_storage_root)
|
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, sab_storage_root=settings.sab_storage_root)
|
||||||
job_id = str(item.get("nzo_id") or item.get("nzoid") or item.get("name") or "")
|
job_id = str(item.get("nzo_id") or item.get("nzoid") or item.get("name") or "")
|
||||||
@@ -449,8 +426,7 @@ async def sync_queue() -> None:
|
|||||||
for video in scan_videos(readiness.storage):
|
for video in scan_videos(readiness.storage):
|
||||||
state.upsert_queue_item(source_type="sab", source_id=str(video.path), source_path=video.path, name=video.path.name, state="ready", reason=readiness.reason, relative_path=str(video.relative_path), size=video.size, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
|
state.upsert_queue_item(source_type="sab", source_id=str(video.path), source_path=video.path, name=video.path.name, state="ready", reason=readiness.reason, relative_path=str(video.relative_path), size=video.size, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
|
||||||
else:
|
else:
|
||||||
pending_state = "waiting_for_sab" if readiness.state not in {"failed", "skipped"} else readiness.state
|
state.upsert_queue_item(source_type="sab", source_id=job_id, source_path=readiness.storage, name=str(item.get("name") or job_id), state=readiness.state, reason=readiness.reason, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
|
||||||
state.upsert_queue_item(source_type="sab", source_id=job_id, source_path=readiness.storage, name=str(item.get("name") or job_id), state=pending_state, reason=readiness.reason, job_id=job_id, sab_category=str(item.get("category") or item.get("cat") or ""))
|
|
||||||
|
|
||||||
|
|
||||||
def queue_jobs() -> list[dict[str, object]]:
|
def queue_jobs() -> list[dict[str, object]]:
|
||||||
@@ -476,27 +452,20 @@ def serialize_queue_item(item: dict[str, object]) -> dict[str, object]:
|
|||||||
"first_seen_at": item["first_seen_at"],
|
"first_seen_at": item["first_seen_at"],
|
||||||
"updated_at": item["updated_at"],
|
"updated_at": item["updated_at"],
|
||||||
"completed_at": item["completed_at"],
|
"completed_at": item["completed_at"],
|
||||||
"attempt_count": item.get("attempt_count", 0),
|
|
||||||
"claimed_by": item.get("claimed_by"),
|
|
||||||
"next_retry_at": item.get("next_retry_at"),
|
|
||||||
"sab_status": state_name if source_type == "sab" else None,
|
"sab_status": state_name if source_type == "sab" else None,
|
||||||
"sab_category": item.get("sab_category") if source_type == "sab" else None,
|
"sab_category": item.get("sab_category") if source_type == "sab" else None,
|
||||||
"can_run_now": state_name in {"ready", "failed", "retrying"} and not item.get("claimed_by"),
|
"can_run_now": state_name in {"ready", "manual_batch", "failed"},
|
||||||
"can_retry": state_name in {"failed", "skipped"},
|
"can_retry": state_name in {"failed", "skipped"},
|
||||||
"can_ignore": state_name not in {"imported", "skipped", "importing"} and not item.get("claimed_by"),
|
"can_ignore": state_name not in {"imported", "skipped"},
|
||||||
"can_remove": not item.get("claimed_by"),
|
"can_remove": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def job_group(state_name: str, source_type: str) -> str:
|
def job_group(state_name: str, source_type: str) -> str:
|
||||||
if source_type == "manual":
|
if source_type == "manual":
|
||||||
return "manual_batch"
|
return "manual_batch"
|
||||||
if state_name in {"detected", "waiting_for_sab"}:
|
|
||||||
return "sab_processing"
|
|
||||||
if state_name == "ready":
|
if state_name == "ready":
|
||||||
return "ready"
|
return "ready"
|
||||||
if state_name == "retrying":
|
|
||||||
return "failed"
|
|
||||||
if state_name in {"importing", "copying"}:
|
if state_name in {"importing", "copying"}:
|
||||||
return "importing"
|
return "importing"
|
||||||
if state_name == "failed":
|
if state_name == "failed":
|
||||||
@@ -535,7 +504,7 @@ def sync_manual_queue() -> None:
|
|||||||
for video in scan_videos(Path(batch["path"])):
|
for video in scan_videos(Path(batch["path"])):
|
||||||
source_id = str(video.path)
|
source_id = str(video.path)
|
||||||
seen.add(source_id)
|
seen.add(source_id)
|
||||||
state.upsert_queue_item(source_type="manual", source_id=source_id, source_path=video.path, name=video.path.name, state="ready", reason="manual batch detected", relative_path=str(video.path.relative_to(root)), size=video.size, batch_id=batch["id"])
|
state.upsert_queue_item(source_type="manual", source_id=source_id, source_path=video.path, name=video.path.name, state="manual_batch", relative_path=str(video.path.relative_to(root)), size=video.size, batch_id=batch["id"])
|
||||||
state.remove_missing_manual_items(batch["id"], seen)
|
state.remove_missing_manual_items(batch["id"], seen)
|
||||||
|
|
||||||
|
|
||||||
@@ -563,50 +532,48 @@ async def _import_ready_sab_jobs(importer: Importer, force: bool = False) -> int
|
|||||||
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, force_status=force, sab_storage_root=settings.sab_storage_root)
|
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, force_status=force, sab_storage_root=settings.sab_storage_root)
|
||||||
if readiness.storage is None or (not readiness.ready and not force):
|
if readiness.storage is None or (not readiness.ready and not force):
|
||||||
continue
|
continue
|
||||||
job_id = str(item.get("nzo_id") or item.get("nzoid") or item.get("name") or "")
|
|
||||||
sab_category = str(item.get("category") or item.get("cat") or "")
|
|
||||||
for video in scan_videos(readiness.storage):
|
for video in scan_videos(readiness.storage):
|
||||||
if consume_cancel_request():
|
if consume_cancel_request():
|
||||||
return imported
|
return imported
|
||||||
row = state.upsert_queue_item(source_type="sab", source_id=str(video.path), source_path=video.path, name=video.path.name, state="ready", reason=readiness.reason, relative_path=str(video.relative_path), size=video.size, job_id=job_id, sab_category=sab_category)
|
set_current_job(str(video.path))
|
||||||
claimed = state.claim_queue_item(row["id"], WORKER_ID, {"ready", "failed", "retrying"})
|
try:
|
||||||
if claimed is not None:
|
result = importer.import_file(video.path, should_cancel=consume_cancel_request)
|
||||||
imported += _import_queue_item(claimed, importer, force=force, from_worker=True)
|
state.add_history(result.source, result.target, "imported", result.bytes)
|
||||||
|
state.mark_queue_item("sab", str(video.path), "imported")
|
||||||
|
imported += 1
|
||||||
|
except ImportCancelled:
|
||||||
|
state.add_history(video.path, video.path, "cancelled", 0, "cancelled")
|
||||||
|
state.mark_queue_item("sab", str(video.path), "skipped", "cancelled")
|
||||||
|
return imported
|
||||||
|
except Exception as exc:
|
||||||
|
state.add_history(video.path, video.path, "failed", 0, exc.__class__.__name__)
|
||||||
|
state.mark_queue_item("sab", str(video.path), "failed", exc.__class__.__name__)
|
||||||
|
finally:
|
||||||
|
set_current_job(None)
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
def _import_queue_item(item: dict[str, object], importer: Importer, *, force: bool = False, from_worker: bool = False) -> int:
|
def _import_queue_item(item: dict[str, object], importer: Importer) -> int:
|
||||||
if item["source_type"] not in {"sab", "manual"} or item["state"] not in {"ready", "manual_batch", "failed", "importing", "retrying", "waiting_for_sab"}:
|
if item["source_type"] not in {"sab", "manual"} or item["state"] not in {"ready", "manual_batch", "failed"}:
|
||||||
return 0
|
return 0
|
||||||
source_path = item.get("source_path")
|
source_path = item.get("source_path")
|
||||||
if not source_path:
|
if not source_path:
|
||||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "failed", "missing source path", increment_attempts=True)
|
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "failed", "missing source path")
|
||||||
return 0
|
return 0
|
||||||
source = Path(str(source_path))
|
source = Path(str(source_path))
|
||||||
if not source.exists():
|
set_current_job(str(source))
|
||||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "failed", "missing source file", increment_attempts=True)
|
|
||||||
return 0
|
|
||||||
if not from_worker:
|
|
||||||
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "importing", item.get("reason"))
|
|
||||||
started = set_current_job(str(source))
|
|
||||||
try:
|
try:
|
||||||
result = importer.import_file(source, should_cancel=consume_cancel_request, on_progress=lambda copied, total: set_current_job(str(source), bytes_copied=copied, total_bytes=total, started_at=started))
|
result = importer.import_file(source, should_cancel=consume_cancel_request)
|
||||||
state.add_history(result.source, result.target, "imported", result.bytes)
|
state.add_history(result.source, result.target, "imported", result.bytes)
|
||||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "imported", increment_attempts=True)
|
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "imported")
|
||||||
return 1
|
return 1
|
||||||
except ImportCancelled:
|
except ImportCancelled:
|
||||||
state.add_history(source, source, "cancelled", 0, "cancelled")
|
state.add_history(source, source, "cancelled", 0, "cancelled")
|
||||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "skipped", "cancelled", increment_attempts=True)
|
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "skipped", "cancelled")
|
||||||
return 0
|
return 0
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
state.add_history(source, source, "failed", 0, exc.__class__.__name__)
|
state.add_history(source, source, "failed", 0, exc.__class__.__name__)
|
||||||
attempts = int(item.get("attempt_count") or 0) + 1
|
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "failed", exc.__class__.__name__)
|
||||||
if attempts >= MAX_RETRY_ATTEMPTS:
|
|
||||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), "failed", exc.__class__.__name__, increment_attempts=True)
|
|
||||||
else:
|
|
||||||
next_state = "retrying" if from_worker or force else "failed"
|
|
||||||
retry_delay = RETRY_DELAY_SECONDS if next_state == "retrying" else None
|
|
||||||
state.mark_queue_item_result(str(item["source_type"]), str(item["source_id"]), next_state, exc.__class__.__name__, increment_attempts=True, next_retry_seconds=retry_delay)
|
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
set_current_job(None)
|
set_current_job(None)
|
||||||
@@ -622,24 +589,26 @@ def _import_manual_batches(importer: Importer) -> int:
|
|||||||
for item in items:
|
for item in items:
|
||||||
if consume_cancel_request():
|
if consume_cancel_request():
|
||||||
return imported
|
return imported
|
||||||
claimed = state.claim_queue_item(item["id"], WORKER_ID, {"ready", "failed", "retrying"})
|
source = Path(item["source_path"])
|
||||||
if claimed is not None:
|
set_current_job(str(source))
|
||||||
imported += _import_queue_item(claimed, importer, force=True, from_worker=True)
|
try:
|
||||||
if not scan_videos(path) and not state.batch_has_active_items(batch["id"]):
|
result = importer.import_file(source, should_cancel=consume_cancel_request)
|
||||||
|
state.add_history(result.source, result.target, "imported", result.bytes)
|
||||||
|
state.mark_queue_item("manual", item["source_id"], "imported")
|
||||||
|
imported += 1
|
||||||
|
except ImportCancelled:
|
||||||
|
state.add_history(source, source, "cancelled", 0, "cancelled")
|
||||||
|
state.mark_queue_item("manual", item["source_id"], "skipped", "cancelled")
|
||||||
|
return imported
|
||||||
|
except Exception as exc:
|
||||||
|
state.add_history(source, source, "failed", 0, exc.__class__.__name__)
|
||||||
|
state.mark_queue_item("manual", item["source_id"], "failed", exc.__class__.__name__)
|
||||||
|
finally:
|
||||||
|
set_current_job(None)
|
||||||
|
if not scan_videos(path):
|
||||||
state.complete_manual_batch(batch["id"])
|
state.complete_manual_batch(batch["id"])
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
def startup_queue_worker() -> None:
|
|
||||||
ensure_worker_running()
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
|
||||||
def shutdown_queue_worker() -> None:
|
|
||||||
if stop_worker(wait=True):
|
|
||||||
state.release_stale_claims()
|
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
uvicorn.run("importarr.main:app", host=settings.bind_host, port=settings.bind_port, reload=False)
|
uvicorn.run("importarr.main:app", host=settings.bind_host, port=settings.bind_port, reload=False)
|
||||||
|
|||||||
+9
-167
@@ -1,35 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
ACTIVE_QUEUE_STATES = {
|
|
||||||
"detected",
|
|
||||||
"waiting_for_sab",
|
|
||||||
"ready",
|
|
||||||
"importing",
|
|
||||||
"retrying",
|
|
||||||
}
|
|
||||||
|
|
||||||
TERMINAL_QUEUE_STATES = {"imported", "failed", "skipped"}
|
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
def __init__(self, path: Path):
|
def __init__(self, path: Path):
|
||||||
self.path = path
|
self.path = path
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._lock = threading.RLock()
|
|
||||||
self.conn = sqlite3.connect(self.path, check_same_thread=False)
|
self.conn = sqlite3.connect(self.path, check_same_thread=False)
|
||||||
self.conn.row_factory = sqlite3.Row
|
self.conn.row_factory = sqlite3.Row
|
||||||
self.conn.execute("pragma journal_mode=WAL")
|
|
||||||
self.conn.execute("pragma busy_timeout = 5000")
|
|
||||||
self.migrate()
|
self.migrate()
|
||||||
|
|
||||||
def migrate(self) -> None:
|
def migrate(self) -> None:
|
||||||
with self._lock:
|
|
||||||
self.conn.executescript(
|
self.conn.executescript(
|
||||||
"""
|
"""
|
||||||
create table if not exists manual_batches (
|
create table if not exists manual_batches (
|
||||||
@@ -63,11 +47,6 @@ class State:
|
|||||||
batch_id integer,
|
batch_id integer,
|
||||||
job_id text,
|
job_id text,
|
||||||
sab_category text,
|
sab_category text,
|
||||||
attempt_count integer not null default 0,
|
|
||||||
next_retry_at text,
|
|
||||||
last_error text,
|
|
||||||
claimed_by text,
|
|
||||||
claimed_at text,
|
|
||||||
first_seen_at text not null default current_timestamp,
|
first_seen_at text not null default current_timestamp,
|
||||||
updated_at text not null default current_timestamp,
|
updated_at text not null default current_timestamp,
|
||||||
completed_at text,
|
completed_at text,
|
||||||
@@ -78,25 +57,13 @@ class State:
|
|||||||
columns = {row["name"] for row in self.conn.execute("pragma table_info(import_queue_items)")}
|
columns = {row["name"] for row in self.conn.execute("pragma table_info(import_queue_items)")}
|
||||||
if "sab_category" not in columns:
|
if "sab_category" not in columns:
|
||||||
self.conn.execute("alter table import_queue_items add column sab_category text")
|
self.conn.execute("alter table import_queue_items add column sab_category text")
|
||||||
if "attempt_count" not in columns:
|
|
||||||
self.conn.execute("alter table import_queue_items add column attempt_count integer not null default 0")
|
|
||||||
if "next_retry_at" not in columns:
|
|
||||||
self.conn.execute("alter table import_queue_items add column next_retry_at text")
|
|
||||||
if "last_error" not in columns:
|
|
||||||
self.conn.execute("alter table import_queue_items add column last_error text")
|
|
||||||
if "claimed_by" not in columns:
|
|
||||||
self.conn.execute("alter table import_queue_items add column claimed_by text")
|
|
||||||
if "claimed_at" not in columns:
|
|
||||||
self.conn.execute("alter table import_queue_items add column claimed_at text")
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_app_state(self, key: str, default: str | None = None) -> str | None:
|
def get_app_state(self, key: str, default: str | None = None) -> str | None:
|
||||||
with self._lock:
|
|
||||||
row = self.conn.execute("select value from app_state where key = ?", (key,)).fetchone()
|
row = self.conn.execute("select value from app_state where key = ?", (key,)).fetchone()
|
||||||
return row["value"] if row else default
|
return row["value"] if row else default
|
||||||
|
|
||||||
def set_app_state(self, key: str, value: str) -> None:
|
def set_app_state(self, key: str, value: str) -> None:
|
||||||
with self._lock:
|
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"insert into app_state(key, value) values (?, ?) on conflict(key) do update set value=excluded.value",
|
"insert into app_state(key, value) values (?, ?) on conflict(key) do update set value=excluded.value",
|
||||||
(key, value),
|
(key, value),
|
||||||
@@ -104,13 +71,11 @@ class State:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def add_manual_batch(self, path: Path) -> dict[str, Any]:
|
def add_manual_batch(self, path: Path) -> dict[str, Any]:
|
||||||
with self._lock:
|
|
||||||
self.conn.execute("insert or ignore into manual_batches(path) values (?)", (str(path),))
|
self.conn.execute("insert or ignore into manual_batches(path) values (?)", (str(path),))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return self.get_manual_batch_by_path(path)
|
return self.get_manual_batch_by_path(path)
|
||||||
|
|
||||||
def get_manual_batch_by_path(self, path: Path) -> dict[str, Any]:
|
def get_manual_batch_by_path(self, path: Path) -> dict[str, Any]:
|
||||||
with self._lock:
|
|
||||||
row = self.conn.execute("select * from manual_batches where path = ?", (str(path),)).fetchone()
|
row = self.conn.execute("select * from manual_batches where path = ?", (str(path),)).fetchone()
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
@@ -119,22 +84,18 @@ class State:
|
|||||||
if active_only:
|
if active_only:
|
||||||
sql += " where status = 'active'"
|
sql += " where status = 'active'"
|
||||||
sql += " order by created_at desc"
|
sql += " order by created_at desc"
|
||||||
with self._lock:
|
|
||||||
return [dict(row) for row in self.conn.execute(sql)]
|
return [dict(row) for row in self.conn.execute(sql)]
|
||||||
|
|
||||||
def delete_manual_batch(self, batch_id: int) -> None:
|
def delete_manual_batch(self, batch_id: int) -> None:
|
||||||
with self._lock:
|
|
||||||
self.conn.execute("delete from manual_batches where id = ?", (batch_id,))
|
self.conn.execute("delete from manual_batches where id = ?", (batch_id,))
|
||||||
self.conn.execute("delete from import_queue_items where batch_id = ? and source_type = 'manual'", (batch_id,))
|
self.conn.execute("delete from import_queue_items where batch_id = ? and source_type = 'manual'", (batch_id,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def complete_manual_batch(self, batch_id: int) -> None:
|
def complete_manual_batch(self, batch_id: int) -> None:
|
||||||
with self._lock:
|
|
||||||
self.conn.execute("update manual_batches set status='completed', completed_at=current_timestamp where id=?", (batch_id,))
|
self.conn.execute("update manual_batches set status='completed', completed_at=current_timestamp where id=?", (batch_id,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def add_history(self, source: Path, target: Path, status: str, bytes_count: int = 0, error: str | None = None) -> None:
|
def add_history(self, source: Path, target: Path, status: str, bytes_count: int = 0, error: str | None = None) -> None:
|
||||||
with self._lock:
|
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"insert into import_history(source,target,status,bytes,error,completed_at) values (?,?,?,?,?,case when ? in ('imported','failed') then current_timestamp else null end)",
|
"insert into import_history(source,target,status,bytes,error,completed_at) values (?,?,?,?,?,case when ? in ('imported','failed') then current_timestamp else null end)",
|
||||||
(str(source), str(target), status, bytes_count, error, status),
|
(str(source), str(target), status, bytes_count, error, status),
|
||||||
@@ -155,9 +116,7 @@ class State:
|
|||||||
batch_id: int | None = None,
|
batch_id: int | None = None,
|
||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
sab_category: str | None = None,
|
sab_category: str | None = None,
|
||||||
preserve_finished_state: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with self._lock:
|
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""
|
"""
|
||||||
insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id, sab_category)
|
insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id, sab_category)
|
||||||
@@ -165,140 +124,35 @@ class State:
|
|||||||
on conflict(source_type, source_id) do update set
|
on conflict(source_type, source_id) do update set
|
||||||
source_path=excluded.source_path,
|
source_path=excluded.source_path,
|
||||||
name=excluded.name,
|
name=excluded.name,
|
||||||
state=case
|
state=excluded.state,
|
||||||
when ? and import_queue_items.state in ('imported','failed','skipped','importing','retrying') then import_queue_items.state
|
reason=excluded.reason,
|
||||||
else excluded.state
|
|
||||||
end,
|
|
||||||
reason=case
|
|
||||||
when ? and import_queue_items.state in ('imported','failed','skipped','importing','retrying') then coalesce(import_queue_items.reason, excluded.reason)
|
|
||||||
else excluded.reason
|
|
||||||
end,
|
|
||||||
relative_path=excluded.relative_path,
|
relative_path=excluded.relative_path,
|
||||||
size=excluded.size,
|
size=excluded.size,
|
||||||
batch_id=excluded.batch_id,
|
batch_id=excluded.batch_id,
|
||||||
job_id=excluded.job_id,
|
job_id=excluded.job_id,
|
||||||
sab_category=excluded.sab_category,
|
sab_category=excluded.sab_category,
|
||||||
updated_at=current_timestamp,
|
updated_at=current_timestamp,
|
||||||
next_retry_at=case
|
completed_at=case when excluded.state in ('imported','failed','skipped') then current_timestamp else null end
|
||||||
when excluded.state = 'retrying' then coalesce(import_queue_items.next_retry_at, excluded.next_retry_at)
|
|
||||||
when excluded.state = 'ready' then null
|
|
||||||
else import_queue_items.next_retry_at
|
|
||||||
end,
|
|
||||||
completed_at=case
|
|
||||||
when ? and import_queue_items.state in ('imported','failed','skipped') then import_queue_items.completed_at
|
|
||||||
when excluded.state in ('imported','failed','skipped') then current_timestamp
|
|
||||||
else null
|
|
||||||
end
|
|
||||||
""",
|
""",
|
||||||
(source_type, source_id, str(source_path) if source_path else None, name, state, reason, relative_path, size, batch_id, job_id, sab_category, preserve_finished_state, preserve_finished_state, preserve_finished_state),
|
(source_type, source_id, str(source_path) if source_path else None, name, state, reason, relative_path, size, batch_id, job_id, sab_category),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
row = self.conn.execute("select * from import_queue_items where source_type = ? and source_id = ?", (source_type, source_id)).fetchone()
|
row = self.conn.execute("select * from import_queue_items where source_type = ? and source_id = ?", (source_type, source_id)).fetchone()
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
def mark_queue_item(self, source_type: str, source_id: str, state: str, reason: str | None = None) -> None:
|
def mark_queue_item(self, source_type: str, source_id: str, state: str, reason: str | None = None) -> None:
|
||||||
with self._lock:
|
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"update import_queue_items set state=?, reason=?, updated_at=current_timestamp, claimed_by=null, claimed_at=null, next_retry_at=case when ?='ready' then null else next_retry_at end, completed_at=case when ? in ('imported','failed','skipped') then current_timestamp else completed_at end where source_type=? and source_id=?",
|
"update import_queue_items set state=?, reason=?, updated_at=current_timestamp, completed_at=case when ? in ('imported','failed','skipped') then current_timestamp else completed_at end where source_type=? and source_id=?",
|
||||||
(state, reason, state, state, source_type, source_id),
|
(state, reason, state, source_type, source_id),
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def claim_next_queue_item(self, worker_id: str) -> dict[str, Any] | None:
|
|
||||||
with self._lock:
|
|
||||||
row = self.conn.execute(
|
|
||||||
"""
|
|
||||||
update import_queue_items
|
|
||||||
set state='importing', claimed_by=?, claimed_at=current_timestamp, updated_at=current_timestamp
|
|
||||||
where id = (
|
|
||||||
select id from import_queue_items
|
|
||||||
where claimed_by is null
|
|
||||||
and (state = 'ready' or (state = 'retrying' and (next_retry_at is null or next_retry_at <= current_timestamp)))
|
|
||||||
order by case state when 'ready' then 0 else 1 end, updated_at asc, id asc
|
|
||||||
limit 1
|
|
||||||
) and claimed_by is null
|
|
||||||
returning *
|
|
||||||
""",
|
|
||||||
(worker_id,),
|
|
||||||
).fetchone()
|
|
||||||
self.conn.commit()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
def release_stale_claims(self) -> int:
|
|
||||||
with self._lock:
|
|
||||||
cursor = self.conn.execute(
|
|
||||||
"update import_queue_items set state='retrying', claimed_by=null, claimed_at=null, updated_at=current_timestamp where state='importing'"
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return cursor.rowcount
|
|
||||||
|
|
||||||
def claim_queue_item(self, item_id: int, worker_id: str, allowed_states: set[str]) -> dict[str, Any] | None:
|
|
||||||
placeholders = ",".join("?" for _ in allowed_states)
|
|
||||||
with self._lock:
|
|
||||||
row = self.conn.execute(
|
|
||||||
f"update import_queue_items set state='importing', claimed_by=?, claimed_at=current_timestamp, updated_at=current_timestamp where id=? and state in ({placeholders}) and claimed_by is null returning *",
|
|
||||||
(worker_id, item_id, *allowed_states),
|
|
||||||
).fetchone()
|
|
||||||
self.conn.commit()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
def transition_queue_item_if_unclaimed(self, item_id: int, allowed_states: set[str], new_state: str, reason: str) -> bool:
|
|
||||||
placeholders = ",".join("?" for _ in allowed_states)
|
|
||||||
with self._lock:
|
|
||||||
cursor = self.conn.execute(
|
|
||||||
f"update import_queue_items set state=?, reason=?, updated_at=current_timestamp, next_retry_at=null, completed_at=case when ? in ('failed','skipped') then current_timestamp else null end where id=? and state in ({placeholders}) and claimed_by is null",
|
|
||||||
(new_state, reason, new_state, item_id, *allowed_states),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def mark_queue_item_result(
|
|
||||||
self,
|
|
||||||
source_type: str,
|
|
||||||
source_id: str,
|
|
||||||
state: str,
|
|
||||||
reason: str | None = None,
|
|
||||||
*,
|
|
||||||
increment_attempts: bool = False,
|
|
||||||
next_retry_seconds: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self.conn.execute(
|
|
||||||
"""
|
|
||||||
update import_queue_items
|
|
||||||
set state=?,
|
|
||||||
reason=?,
|
|
||||||
last_error=case when ? in ('failed','retrying','skipped') then ? else null end,
|
|
||||||
attempt_count=attempt_count + ?,
|
|
||||||
next_retry_at=case
|
|
||||||
when ? = 'retrying' and ? is not null then datetime('now', '+' || ? || ' seconds')
|
|
||||||
when ? in ('ready','imported','failed','skipped') then null
|
|
||||||
else next_retry_at
|
|
||||||
end,
|
|
||||||
claimed_by=null,
|
|
||||||
claimed_at=null,
|
|
||||||
updated_at=current_timestamp,
|
|
||||||
completed_at=case when ? in ('imported','failed','skipped') then current_timestamp else null end
|
|
||||||
where source_type=? and source_id=?
|
|
||||||
""",
|
|
||||||
(state, reason, state, reason, 1 if increment_attempts else 0, state, next_retry_seconds, next_retry_seconds, state, state, source_type, source_id),
|
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def delete_queue_item(self, item_id: int) -> bool:
|
def delete_queue_item(self, item_id: int) -> bool:
|
||||||
with self._lock:
|
|
||||||
cursor = self.conn.execute("delete from import_queue_items where id = ?", (item_id,))
|
cursor = self.conn.execute("delete from import_queue_items where id = ?", (item_id,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def delete_queue_item_if_unclaimed(self, item_id: int) -> bool:
|
|
||||||
with self._lock:
|
|
||||||
cursor = self.conn.execute("delete from import_queue_items where id = ? and claimed_by is null", (item_id,))
|
|
||||||
self.conn.commit()
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def delete_queue_items_by_state(self, source_type: str, state: str, reason: str | None = None) -> int:
|
def delete_queue_items_by_state(self, source_type: str, state: str, reason: str | None = None) -> int:
|
||||||
with self._lock:
|
|
||||||
if reason is None:
|
if reason is None:
|
||||||
cursor = self.conn.execute("delete from import_queue_items where source_type = ? and state = ?", (source_type, state))
|
cursor = self.conn.execute("delete from import_queue_items where source_type = ? and state = ?", (source_type, state))
|
||||||
else:
|
else:
|
||||||
@@ -307,34 +161,22 @@ class State:
|
|||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
def get_queue_item(self, item_id: int) -> dict[str, Any] | None:
|
def get_queue_item(self, item_id: int) -> dict[str, Any] | None:
|
||||||
with self._lock:
|
|
||||||
row = self.conn.execute("select * from import_queue_items where id = ?", (item_id,)).fetchone()
|
row = self.conn.execute("select * from import_queue_items where id = ?", (item_id,)).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def remove_missing_manual_items(self, batch_id: int, source_ids: set[str]) -> None:
|
def remove_missing_manual_items(self, batch_id: int, source_ids: set[str]) -> None:
|
||||||
with self._lock:
|
rows = self.conn.execute("select source_id from import_queue_items where source_type='manual' and batch_id=?", (batch_id,)).fetchall()
|
||||||
rows = self.conn.execute("select source_id, state, claimed_by from import_queue_items where source_type='manual' and batch_id=?", (batch_id,)).fetchall()
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row["source_id"] not in source_ids and row["state"] not in TERMINAL_QUEUE_STATES and not row["claimed_by"]:
|
if row["source_id"] not in source_ids:
|
||||||
self.conn.execute("delete from import_queue_items where source_type='manual' and source_id=? and claimed_by is null", (row["source_id"],))
|
self.conn.execute("delete from import_queue_items where source_type='manual' and source_id=?", (row["source_id"],))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def batch_has_active_items(self, batch_id: int) -> bool:
|
|
||||||
with self._lock:
|
|
||||||
row = self.conn.execute(
|
|
||||||
"select 1 from import_queue_items where batch_id = ? and source_type='manual' and state not in ('imported','failed','skipped') limit 1",
|
|
||||||
(batch_id,),
|
|
||||||
).fetchone()
|
|
||||||
return row is not None
|
|
||||||
|
|
||||||
def list_queue_items(self, active_only: bool = True) -> list[dict[str, Any]]:
|
def list_queue_items(self, active_only: bool = True) -> list[dict[str, Any]]:
|
||||||
sql = "select * from import_queue_items"
|
sql = "select * from import_queue_items"
|
||||||
if active_only:
|
if active_only:
|
||||||
sql += " where state not in ('imported','failed','skipped')"
|
sql += " where state not in ('imported','failed','skipped')"
|
||||||
sql += " order by updated_at desc, id desc"
|
sql += " order by updated_at desc, id desc"
|
||||||
with self._lock:
|
|
||||||
return [dict(row) for row in self.conn.execute(sql)]
|
return [dict(row) for row in self.conn.execute(sql)]
|
||||||
|
|
||||||
def list_history(self, limit: int = 100) -> list[dict[str, Any]]:
|
def list_history(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
with self._lock:
|
|
||||||
return [dict(row) for row in self.conn.execute("select * from import_history order by id desc limit ?", (limit,))]
|
return [dict(row) for row in self.conn.execute("select * from import_history order by id desc limit ?", (limit,))]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="theme-color" content="#252B42" />
|
|
||||||
<title>Importarr</title>
|
|
||||||
<script type="module" crossorigin src="/static/assets/app.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/app.css">
|
|
||||||
</head>
|
|
||||||
<body><div id="root"></div></body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>📥 - idle · Importarr {{ status.build.version }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/importarr.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand"><h1>Importarr</h1><p id="ready-state">{{ 'Running' if status.control.queue_mode == 'start' else status.control.queue_mode|capitalize }}</p></div>
|
||||||
|
<div class="top-status"><span>Current</span><strong id="top-current-job">{{ status.current or 'idle' }}</strong></div>
|
||||||
|
<div class="top-controls">
|
||||||
|
<button type="button" data-control="start" aria-label="Start imports">▶</button>
|
||||||
|
<button type="button" data-control="pause" aria-label="Pause imports">⏸</button>
|
||||||
|
<details class="menu">
|
||||||
|
<summary aria-label="Open menu">☰</summary>
|
||||||
|
<div class="menu-panel">
|
||||||
|
<button type="button" id="open-settings">Settings</button>
|
||||||
|
<button type="button" data-control="stop">Stop queue</button>
|
||||||
|
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
|
||||||
|
<button id="force-run" type="button">Force run now</button>
|
||||||
|
<a href="#manual-batches">Manual batches</a>
|
||||||
|
<a href="#service-info">Service info</a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="summary-strip" aria-label="Importarr summary">
|
||||||
|
<article><strong>{{ status.control.queue_mode }}</strong><span>Queue mode</span></article>
|
||||||
|
<article><strong>{{ status.current or 'Idle' }}</strong><span>Current import</span></article>
|
||||||
|
<article><strong>{{ status.imported_total }}</strong><span>Imported</span></article>
|
||||||
|
<article><strong>{{ status.failed_total }}</strong><span>Failed</span></article>
|
||||||
|
</section>
|
||||||
|
<details class="panel packed" id="service-info">
|
||||||
|
<summary>Service info and build details</summary>
|
||||||
|
<dl class="info">
|
||||||
|
<dt>Name</dt><dd>{{ status.build.name }}</dd>
|
||||||
|
<dt>Version</dt><dd>{{ status.build.version }}</dd>
|
||||||
|
<dt>Build date</dt><dd>{{ status.build.build_date }}</dd>
|
||||||
|
<dt>Git SHA</dt><dd>{{ status.build.git_sha }}</dd>
|
||||||
|
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
|
||||||
|
<dt>Python</dt><dd>{{ status.build.python }}</dd>
|
||||||
|
<dt>SAB URL</dt><dd>{{ status.sab_url }}</dd>
|
||||||
|
<dt>SAB API token</dt><dd id="sab-token-status">{{ 'configured' if status.sab_api_key_configured else 'not configured' }}</dd>
|
||||||
|
<dt>Radarr</dt><dd>{{ status.radarr_url or 'not configured' }}</dd>
|
||||||
|
<dt>Sonarr</dt><dd>{{ status.sonarr_url or 'not configured' }}</dd>
|
||||||
|
<dt>Download root</dt><dd>{{ status.download_root }}</dd>
|
||||||
|
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
|
||||||
|
<dt>TV root</dt><dd>{{ status.tv_root }}</dd>
|
||||||
|
<dt>Write auth</dt><dd>{{ 'enabled' if status.auth_enabled else 'disabled' }}</dd>
|
||||||
|
<dt>Queue mode</dt><dd id="queue-mode">{{ status.control.queue_mode }}</dd>
|
||||||
|
<dt>Current job</dt><dd id="current-job">{{ status.current or 'idle' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
<details class="panel packed">
|
||||||
|
<summary>Queue controls</summary>
|
||||||
|
<p>Pause and stop prevent new jobs from being added to the queue. They do not interrupt an import already in progress; use cancel current job for that.</p>
|
||||||
|
<div class="controls">
|
||||||
|
<button type="button" data-control="start">Start</button>
|
||||||
|
<button type="button" data-control="pause">Pause</button>
|
||||||
|
<button type="button" data-control="stop">Stop</button>
|
||||||
|
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="panel packed" id="manual-batches">
|
||||||
|
<summary>Manual batches</summary>
|
||||||
|
<form id="batch-form" class="inline-form">
|
||||||
|
<input name="path" placeholder="folder under download root">
|
||||||
|
<input id="batch-picker" type="file" webkitdirectory directory multiple hidden>
|
||||||
|
<button type="button" id="browse-batch">Browse…</button>
|
||||||
|
<button>Add batch</button>
|
||||||
|
</form>
|
||||||
|
<div class="table-scroll"><table><thead><tr><th>ID</th><th>Status</th><th>Path</th></tr></thead><tbody>
|
||||||
|
{% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %}
|
||||||
|
</tbody></table></div>
|
||||||
|
</details>
|
||||||
|
<section class="panel queue-panel">
|
||||||
|
<div class="section-title"><h2>Queue and history</h2><span>Grouped by processing state</span></div>
|
||||||
|
<div id="jobs">Loading…</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<dialog id="settings-dialog">
|
||||||
|
<form id="settings-form" method="dialog">
|
||||||
|
<div class="section-title"><h2>Settings</h2><button type="button" id="close-settings">Close</button></div>
|
||||||
|
<fieldset>
|
||||||
|
<legend>SABnzbd</legend>
|
||||||
|
<label>SAB URL <input name="sab_url" type="url" value="{{ status.sab_url }}" placeholder="http://sabnzbd:8080" required></label>
|
||||||
|
<label>API token <input name="sab_api_key" type="password" placeholder="{% if status.sab_api_key_configured %}Configured; enter a new token to replace{% else %}SAB API token{% endif %}" autocomplete="off"></label>
|
||||||
|
<button type="button" data-test-connection="sabnzbd">Test SABnzbd connection</button><output id="sabnzbd-test-result"></output>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Radarr</legend>
|
||||||
|
<label>Radarr URL <input name="radarr_url" type="url" value="{{ status.radarr_url }}" placeholder="http://radarr:7878"></label>
|
||||||
|
<label>API token <input name="radarr_api_key" type="password" placeholder="{% if status.radarr_api_key_configured %}Configured; enter a new token to replace{% else %}Radarr API token{% endif %}" autocomplete="off"></label>
|
||||||
|
<button type="button" data-test-connection="radarr">Test Radarr connection</button><output id="radarr-test-result"></output>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Sonarr</legend>
|
||||||
|
<label>Sonarr URL <input name="sonarr_url" type="url" value="{{ status.sonarr_url }}" placeholder="http://sonarr:8989"></label>
|
||||||
|
<label>API token <input name="sonarr_api_key" type="password" placeholder="{% if status.sonarr_api_key_configured %}Configured; enter a new token to replace{% else %}Sonarr API token{% endif %}" autocomplete="off"></label>
|
||||||
|
<button type="button" data-test-connection="sonarr">Test Sonarr connection</button><output id="sonarr-test-result"></output>
|
||||||
|
</fieldset>
|
||||||
|
<p class="hint">Blank token fields clear the stored token. Environment values remain the startup defaults until saved here.</p>
|
||||||
|
<button type="submit">Save settings</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<script>
|
||||||
|
const esc=value=>String(value??'').replace(/[&<>"']/g,ch=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||||||
|
async function postJson(url, body){ const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:body?JSON.stringify(body):undefined}); if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); alert(error.detail||'Request failed'); } return response; }
|
||||||
|
function actionButtons(j){ const buttons=[]; if(j.can_run_now) buttons.push(`<button type="button" data-action="run-now" data-id="${j.id}" title="Run now">▶</button>`); if(j.can_retry) buttons.push(`<button type="button" data-action="retry" data-id="${j.id}" title="Retry">↻</button>`); if(j.can_ignore) buttons.push(`<button type="button" data-action="ignore" data-id="${j.id}" class="warn" title="Ignore">!</button>`); if(j.can_remove) buttons.push(`<button type="button" data-action="remove" data-id="${j.id}" class="danger" title="Remove">🗑</button>`); return buttons.join(' '); }
|
||||||
|
function jobSubtext(j){ return `${esc(j.source_type)}${j.batch_id?' · batch '+esc(j.batch_id):''} · ${esc(j.relative_path||j.storage||j.source_id)}`; }
|
||||||
|
function readiness(j){ return `<span class="state" title="${esc(j.reason||j.state)}">${esc(j.state)}</span>`; }
|
||||||
|
function renderJobs(data){ const groups=(data.groups||[]).filter(group=>group.jobs.length); if(!groups.length) return '<p>No queue items.</p>'; return groups.map(group=>`<section class="job-group"><h3>${esc(group.label)} <span>${group.jobs.length}</span></h3><div class="table-scroll jobs-table"><table><thead><tr><th>File</th><th>Readiness</th><th>SAB</th><th>Actions</th></tr></thead><tbody>${group.jobs.map(j=>`<tr><td><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small></td><td>${readiness(j)}</td><td><small>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</small></td><td class="row-actions">${actionButtons(j)}</td></tr>`).join('')}</tbody></table></div><div class="job-cards">${group.jobs.map(j=>`<article class="job-card"><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small><dl><dt>Readiness</dt><dd>${readiness(j)}</dd><dt>SAB</dt><dd>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</dd></dl><div class="row-actions">${actionButtons(j)}</div></article>`).join('')}</div></section>`).join(''); }
|
||||||
|
async function refresh(){ const r=await fetch('/api/jobs'); const d=await r.json(); document.title=d.jobs.length?`📥 ${d.jobs.length} jobs - Importarr`:'📥 - idle · Importarr'; document.getElementById('jobs').innerHTML=renderJobs(d); if(d.control){ const current=d.control.current||'idle'; document.getElementById('queue-mode').textContent=d.control.queue_mode; document.getElementById('current-job').textContent=current; document.getElementById('top-current-job').textContent=current; document.getElementById('ready-state').textContent=d.control.queue_mode==='start'?'Running':d.control.queue_mode; } }
|
||||||
|
document.getElementById('jobs').addEventListener('click', async e=>{ const button=e.target.closest('button[data-action]'); if(!button)return; const action=button.dataset.action; const destructive=['ignore','remove'].includes(action); if(destructive&&!confirm(`${action} this Importarr queue item?`)) return; await postJson(`/api/queue-items/${button.dataset.id}/action`,{action}); await refresh(); });
|
||||||
|
document.querySelectorAll('[data-control]').forEach(button=>button.addEventListener('click', async()=>{ if(button.dataset.control==='cancel-current'&&!confirm('Cancel the current import job?')) return; await postJson(`/api/control/${button.dataset.control}`); await refresh(); }));
|
||||||
|
document.getElementById('browse-batch').addEventListener('click',()=>document.getElementById('batch-picker').click());
|
||||||
|
document.getElementById('batch-picker').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f)return; const top=(f.webkitRelativePath||'').split('/')[0]; if(top) document.querySelector('#batch-form [name="path"]').value=top; });
|
||||||
|
document.getElementById('batch-form').addEventListener('submit', async e=>{ e.preventDefault(); const response=await postJson('/api/manual-batches',{path:e.target.path.value}); if(response.ok) location.reload(); });
|
||||||
|
document.getElementById('open-settings').addEventListener('click',()=>document.getElementById('settings-dialog').showModal());
|
||||||
|
document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').close());
|
||||||
|
document.addEventListener('click',e=>{ document.querySelectorAll('details.menu[open]').forEach(menu=>{ if(!menu.contains(e.target)) menu.removeAttribute('open'); }); });
|
||||||
|
document.getElementById('settings-dialog').addEventListener('click',e=>{ if(e.target===e.currentTarget) e.currentTarget.close(); });
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', async e=>{ e.preventDefault(); const body=Object.fromEntries(new FormData(e.target)); const response=await postJson('/api/settings',body); if(response.ok){ const data=await response.json(); document.getElementById('sab-token-status').textContent=data.sab_api_key_configured?'configured':'not configured'; ['sab_api_key','radarr_api_key','sonarr_api_key'].forEach(name=>e.target.elements[name].value=''); document.getElementById('settings-dialog').close(); } });
|
||||||
|
document.querySelectorAll('[data-test-connection]').forEach(button=>button.addEventListener('click', async()=>{ const form=document.getElementById('settings-form'); const service=button.dataset.testConnection; const prefix=service==='sabnzbd'?'sab':service; const output=document.getElementById(`${service}-test-result`); output.textContent='Testing…'; output.className=''; const response=await postJson('/api/settings/test-connection',{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}); if(response.ok){ const data=await response.json(); output.textContent=data.message; output.className=data.ok?'success':'error'; } }));
|
||||||
|
document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); });
|
||||||
|
refresh(); setInterval(refresh, 10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2
-4
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "importarr"
|
name = "importarr"
|
||||||
version = "0.1.3"
|
version = "0.1.0"
|
||||||
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"
|
||||||
@@ -12,6 +12,7 @@ license = "MIT"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.111",
|
"fastapi>=0.111",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"jinja2>=3.1",
|
||||||
"pydantic>=2.7",
|
"pydantic>=2.7",
|
||||||
"uvicorn[standard]>=0.30",
|
"uvicorn[standard]>=0.30",
|
||||||
]
|
]
|
||||||
@@ -23,9 +24,6 @@ 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 = ["."]
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import fcntl
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_repo_upgrade_refuses_concurrent_run(tmp_path):
|
|
||||||
script = Path(__file__).parents[1] / "deploy" / "repo-upgrade.sh"
|
|
||||||
lock_path = tmp_path / "repo-upgrade.lock"
|
|
||||||
bin_dir = tmp_path / "bin"
|
|
||||||
bin_dir.mkdir()
|
|
||||||
fake_id = bin_dir / "id"
|
|
||||||
fake_id.write_text("#!/bin/sh\nprintf '0\\n'\n")
|
|
||||||
fake_id.chmod(0o755)
|
|
||||||
|
|
||||||
with lock_path.open("w") as lock:
|
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
result = subprocess.run(
|
|
||||||
["sh", str(script)],
|
|
||||||
env={
|
|
||||||
**os.environ,
|
|
||||||
"PATH": f"{bin_dir}:{os.environ['PATH']}",
|
|
||||||
"IMPORTARR_ENV_FILE": str(tmp_path / "missing.env"),
|
|
||||||
"IMPORTARR_PREFIX": str(tmp_path),
|
|
||||||
},
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.returncode == 1
|
|
||||||
assert "another Importarr upgrade is already running" in result.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_wheel_contains_built_frontend_assets(tmp_path):
|
|
||||||
root = Path(__file__).parents[1]
|
|
||||||
subprocess.run(
|
|
||||||
[sys.executable, "-m", "pip", "wheel", "--no-deps", "--wheel-dir", str(tmp_path), str(root)],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
wheel = next(tmp_path.glob("importarr-*.whl"))
|
|
||||||
with zipfile.ZipFile(wheel) as archive:
|
|
||||||
packaged = set(archive.namelist())
|
|
||||||
|
|
||||||
assert "importarr/static/index.html" in packaged
|
|
||||||
assert "importarr/static/assets/app.js" in packaged
|
|
||||||
assert "importarr/static/assets/app.css" in packaged
|
|
||||||
|
|
||||||
|
|
||||||
def test_repo_upgrade_forces_fresh_install_without_skipping_dependencies():
|
|
||||||
script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text()
|
|
||||||
|
|
||||||
assert "pip\" install --upgrade --force-reinstall --no-cache-dir" in script
|
|
||||||
assert "--no-deps" not in script
|
|
||||||
assert '"${WHEEL:-$REPO_DIR}"' in script
|
|
||||||
|
|
||||||
|
|
||||||
def test_deploy_workflow_pins_repository_to_triggering_revision():
|
|
||||||
workflow = (Path(__file__).parents[1] / ".gitea/workflows/deploy.yml").read_text()
|
|
||||||
|
|
||||||
assert "DEPLOY_SHA: ${{ github.sha }}" in workflow
|
|
||||||
assert "git fetch --prune origin main" in workflow
|
|
||||||
assert "git checkout --detach $DEPLOY_SHA" in workflow
|
|
||||||
assert 'repo-upgrade.sh \\"\\" $DEPLOY_SHA' in workflow
|
|
||||||
|
|
||||||
|
|
||||||
def test_deploy_workflow_runs_repository_upgrade_over_ssh():
|
|
||||||
workflow = (Path(__file__).parents[1] / ".gitea/workflows/deploy.yml").read_text()
|
|
||||||
|
|
||||||
assert 'ssh -p "$DEPLOY_PORT" -o BatchMode=yes "$DEPLOY_USER@$DEPLOY_HOST"' in workflow
|
|
||||||
assert 'test -z \\"\\$(git status --porcelain)\\"' in workflow
|
|
||||||
assert "sudo -n /bin/sh -c 'set -eu; cd /opt/importarr/repo" in workflow
|
|
||||||
|
|
||||||
|
|
||||||
def test_repo_upgrade_uses_pinned_revision_for_build_metadata():
|
|
||||||
script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text()
|
|
||||||
|
|
||||||
assert 'test "$(git rev-parse HEAD)" = "$(git rev-parse "$REVISION^{commit}")"' in script
|
|
||||||
assert 'GIT_SHA="$(git rev-parse --short=12 HEAD' in script
|
|
||||||
assert 'VERSION="$(git rev-parse --short=12 HEAD)"' in script
|
|
||||||
|
|
||||||
|
|
||||||
def test_repo_upgrade_deploys_only_origin_main():
|
|
||||||
script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text()
|
|
||||||
|
|
||||||
assert "git fetch --prune origin main" in script
|
|
||||||
assert "git checkout -B main origin/main" in script
|
|
||||||
assert "refs/tags" not in script
|
|
||||||
|
|
||||||
|
|
||||||
def test_repo_upgrade_removes_obsolete_update_command():
|
|
||||||
script = (Path(__file__).parents[1] / "deploy/repo-upgrade.sh").read_text()
|
|
||||||
|
|
||||||
assert "IMPORTARR_UPDATE_COMMAND=/d" in script
|
|
||||||
@@ -48,7 +48,7 @@ def test_manual_queue_items_are_persisted_and_imported(tmp_path, monkeypatch):
|
|||||||
queued = main.state.list_queue_items()
|
queued = main.state.list_queue_items()
|
||||||
assert len(queued) == 1
|
assert len(queued) == 1
|
||||||
assert queued[0]["source_type"] == "manual"
|
assert queued[0]["source_type"] == "manual"
|
||||||
assert queued[0]["state"] == "ready"
|
assert queued[0]["state"] == "manual_batch"
|
||||||
|
|
||||||
assert main._import_manual_batches(main.Importer(movies, tv)) == 1
|
assert main._import_manual_batches(main.Importer(movies, tv)) == 1
|
||||||
assert main.state.list_queue_items() == []
|
assert main.state.list_queue_items() == []
|
||||||
|
|||||||
+57
-301
@@ -1,6 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from importarr.config import Settings
|
from importarr.config import Settings
|
||||||
from importarr.state import State
|
from importarr.state import State
|
||||||
|
|
||||||
@@ -45,57 +42,6 @@ def test_start_reenables_manual_queue_sync(tmp_path, monkeypatch):
|
|||||||
assert len(main.state.list_queue_items()) == 1
|
assert len(main.state.list_queue_items()) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_default_control_commands_target_manual_import_service(monkeypatch):
|
|
||||||
monkeypatch.delenv("IMPORTARR_START_COMMAND", raising=False)
|
|
||||||
monkeypatch.delenv("IMPORTARR_STOP_COMMAND", raising=False)
|
|
||||||
monkeypatch.delenv("IMPORTARR_RESTART_COMMAND", raising=False)
|
|
||||||
|
|
||||||
settings = Settings.from_env()
|
|
||||||
|
|
||||||
assert settings.start_command == ["systemctl", "start", "manual-media-import.service"]
|
|
||||||
assert settings.stop_command == ["systemctl", "stop", "manual-media-import.service"]
|
|
||||||
assert settings.restart_command == ["systemctl", "restart", "manual-media-import.service"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_control_starts_manual_import_service(tmp_path, monkeypatch):
|
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
main.settings.start_command = ["systemctl", "start", "manual-media-import.service"]
|
|
||||||
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_run(command, **kwargs):
|
|
||||||
calls.append(command)
|
|
||||||
return main.subprocess.CompletedProcess(command, 0, stdout="started", stderr="")
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
|
||||||
|
|
||||||
result = main.start_queue()
|
|
||||||
|
|
||||||
assert result["control"]["queue_mode"] == "running"
|
|
||||||
assert result["command_result"]["command"] == ["systemctl", "start", "manual-media-import.service"]
|
|
||||||
assert calls == [["systemctl", "start", "manual-media-import.service"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_control_stops_manual_import_service(tmp_path, monkeypatch):
|
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
main.settings.stop_command = ["systemctl", "stop", "manual-media-import.service"]
|
|
||||||
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_run(command, **kwargs):
|
|
||||||
calls.append(command)
|
|
||||||
return main.subprocess.CompletedProcess(command, 0, stdout="stopped", stderr="")
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
|
||||||
|
|
||||||
result = main.stop_queue()
|
|
||||||
|
|
||||||
assert result["control"]["queue_mode"] == "stopped"
|
|
||||||
assert result["control"]["cancel_requested"] is True
|
|
||||||
assert result["command_result"]["command"] == ["systemctl", "stop", "manual-media-import.service"]
|
|
||||||
assert calls == [["systemctl", "stop", "manual-media-import.service"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_queue_jobs_include_groups_and_manual_context(tmp_path, monkeypatch):
|
def test_queue_jobs_include_groups_and_manual_context(tmp_path, monkeypatch):
|
||||||
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
batch = download / "Release" / "Season 1"
|
batch = download / "Release" / "Season 1"
|
||||||
@@ -109,7 +55,6 @@ def test_queue_jobs_include_groups_and_manual_context(tmp_path, monkeypatch):
|
|||||||
assert jobs[0]["group"] == "manual_batch"
|
assert jobs[0]["group"] == "manual_batch"
|
||||||
assert jobs[0]["relative_path"] == "Release/Season 1/Episode.mkv"
|
assert jobs[0]["relative_path"] == "Release/Season 1/Episode.mkv"
|
||||||
assert jobs[0]["can_run_now"] is True
|
assert jobs[0]["can_run_now"] is True
|
||||||
assert jobs[0]["state"] == "ready"
|
|
||||||
|
|
||||||
|
|
||||||
def test_queue_item_retry_ignore_and_remove_actions(tmp_path, monkeypatch):
|
def test_queue_item_retry_ignore_and_remove_actions(tmp_path, monkeypatch):
|
||||||
@@ -136,8 +81,8 @@ def test_queue_item_run_now_imports_only_selected_item(tmp_path, monkeypatch):
|
|||||||
waiting = batch / "Waiting.mkv"
|
waiting = batch / "Waiting.mkv"
|
||||||
selected.write_bytes(b"selected")
|
selected.write_bytes(b"selected")
|
||||||
waiting.write_bytes(b"waiting")
|
waiting.write_bytes(b"waiting")
|
||||||
selected_row = main.state.upsert_queue_item(source_type="manual", source_id=str(selected), source_path=selected, name=selected.name, state="ready")
|
selected_row = main.state.upsert_queue_item(source_type="manual", source_id=str(selected), source_path=selected, name=selected.name, state="manual_batch")
|
||||||
main.state.upsert_queue_item(source_type="manual", source_id=str(waiting), source_path=waiting, name=waiting.name, state="ready")
|
main.state.upsert_queue_item(source_type="manual", source_id=str(waiting), source_path=waiting, name=waiting.name, state="manual_batch")
|
||||||
|
|
||||||
result = main.queue_item_action(selected_row["id"], main.QueueItemActionRequest(action="run-now"))
|
result = main.queue_item_action(selected_row["id"], main.QueueItemActionRequest(action="run-now"))
|
||||||
|
|
||||||
@@ -147,21 +92,7 @@ def test_queue_item_run_now_imports_only_selected_item(tmp_path, monkeypatch):
|
|||||||
assert waiting.exists()
|
assert waiting.exists()
|
||||||
rows = {row["name"]: row for row in main.state.list_queue_items(active_only=False)}
|
rows = {row["name"]: row for row in main.state.list_queue_items(active_only=False)}
|
||||||
assert rows["Selected.mkv"]["state"] == "imported"
|
assert rows["Selected.mkv"]["state"] == "imported"
|
||||||
assert rows["Waiting.mkv"]["state"] == "ready"
|
assert rows["Waiting.mkv"]["state"] == "manual_batch"
|
||||||
|
|
||||||
|
|
||||||
def test_current_job_status_includes_progress(tmp_path, monkeypatch):
|
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
monkeypatch.setattr(main.time, "time", lambda: 110.0)
|
|
||||||
|
|
||||||
main.set_current_job("Movie.mkv", bytes_copied=50, total_bytes=200, started_at=100.0)
|
|
||||||
|
|
||||||
current = main.control_status()["current"]
|
|
||||||
assert current["file"] == "Movie.mkv"
|
|
||||||
assert current["bytes_copied"] == 50
|
|
||||||
assert current["total_bytes"] == 200
|
|
||||||
assert current["percent"] == 25
|
|
||||||
assert current["elapsed_seconds"] == 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_current_stops_before_next_manual_item(tmp_path, monkeypatch):
|
def test_cancel_current_stops_before_next_manual_item(tmp_path, monkeypatch):
|
||||||
@@ -202,255 +133,80 @@ def test_cancel_current_stops_active_copy(tmp_path, monkeypatch):
|
|||||||
assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped"
|
assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped"
|
||||||
|
|
||||||
|
|
||||||
def test_status_includes_queue_counts(tmp_path, monkeypatch):
|
def test_control_update_runs_configured_command(tmp_path, monkeypatch):
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
main.state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
main.settings.update_command = ["upgrade", "now"]
|
||||||
main.state.upsert_queue_item(source_type="manual", source_id="b", name="B.mkv", state="failed")
|
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})
|
||||||
|
|
||||||
status = main.status()
|
calls = []
|
||||||
|
|
||||||
assert status["queue_total"] == 2
|
def fake_run(command, **kwargs):
|
||||||
assert status["queue_counts"]["ready"] == 1
|
calls.append((command, kwargs))
|
||||||
assert status["queue_counts"]["failed"] == 1
|
return main.subprocess.CompletedProcess(command, 0, stdout="updated", stderr="")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
result = main.update_service()
|
||||||
|
|
||||||
|
assert result["status"] == "update_available"
|
||||||
|
assert result["command_result"]["status"] == "ok"
|
||||||
|
assert result["command_result"]["command"] == ["upgrade", "now"]
|
||||||
|
assert result["command_result"]["stdout"] == "updated"
|
||||||
|
assert calls[0][0] == ["upgrade", "now"]
|
||||||
|
assert calls[0][1].get("shell") is not True
|
||||||
|
|
||||||
|
|
||||||
def test_worker_claimed_failure_retries_item(tmp_path, monkeypatch):
|
def test_control_update_skips_command_when_current(tmp_path, monkeypatch):
|
||||||
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
batch = download / "Release"
|
|
||||||
batch.mkdir(parents=True)
|
|
||||||
source = batch / "A.mkv"
|
|
||||||
source.write_bytes(b"a")
|
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id=str(source), source_path=source, name=source.name, state="ready")
|
|
||||||
claimed = main.state.claim_next_queue_item(main.WORKER_ID)
|
|
||||||
|
|
||||||
class BrokenImporter:
|
|
||||||
def import_file(self, *args, **kwargs):
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
imported = main._import_queue_item(claimed, BrokenImporter(), from_worker=True)
|
|
||||||
updated = main.state.get_queue_item(row["id"])
|
|
||||||
|
|
||||||
assert imported == 0
|
|
||||||
assert updated["state"] == "retrying"
|
|
||||||
assert updated["attempt_count"] == 1
|
|
||||||
assert updated["next_retry_at"] is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_startup_releases_stale_claims_from_previous_worker(tmp_path, monkeypatch):
|
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
main.settings.update_command = ["upgrade", "now"]
|
||||||
main.state.claim_queue_item(row["id"], "old-worker", {"ready"})
|
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})
|
||||||
|
|
||||||
main.ensure_worker_running()
|
def fake_run(command, **kwargs):
|
||||||
updated = main.state.get_queue_item(row["id"])
|
raise AssertionError("update command should not run without a newer release")
|
||||||
main.stop_worker()
|
|
||||||
|
|
||||||
assert updated["state"] == "retrying"
|
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
||||||
assert updated["claimed_by"] is None
|
|
||||||
|
result = main.update_service()
|
||||||
|
|
||||||
|
assert result["status"] == "current"
|
||||||
|
assert result["command"] == ["upgrade", "now"]
|
||||||
|
assert result["update_available"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_run_now_conflicts_when_item_is_claimed(tmp_path, monkeypatch):
|
def test_update_check_compares_latest_release(tmp_path, monkeypatch):
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
monkeypatch.setenv("IMPORTARR_VERSION", "0.1.0")
|
||||||
main.state.claim_queue_item(row["id"], "other-worker", {"ready"})
|
|
||||||
|
|
||||||
try:
|
class FakeResponse:
|
||||||
main.queue_item_action(row["id"], main.QueueItemActionRequest(action="run-now"))
|
def raise_for_status(self):
|
||||||
except main.HTTPException as exc:
|
return None
|
||||||
assert exc.status_code == 409
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected HTTPException")
|
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"tag_name": "v0.2.0", "html_url": "https://example.test/releases/v0.2.0"}
|
||||||
|
|
||||||
def test_bulk_run_now_skips_item_claimed_by_worker(tmp_path, monkeypatch):
|
class FakeClient:
|
||||||
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
|
def __init__(self, timeout):
|
||||||
batch = download / "Release"
|
self.timeout = timeout
|
||||||
batch.mkdir(parents=True)
|
|
||||||
source = batch / "A.mkv"
|
|
||||||
source.write_bytes(b"a")
|
|
||||||
created_batch = main.state.add_manual_batch(batch)
|
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id=str(source), source_path=source, name=source.name, state="ready", batch_id=created_batch["id"])
|
|
||||||
main.state.claim_queue_item(row["id"], "worker", {"ready"})
|
|
||||||
|
|
||||||
assert main._import_manual_batches(main.Importer(movies, tv)) == 0
|
def __enter__(self):
|
||||||
assert source.exists()
|
return self
|
||||||
assert main.state.get_queue_item(row["id"])["claimed_by"] == "worker"
|
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
def test_retry_loses_atomic_race_with_worker_claim(tmp_path, monkeypatch):
|
def get(self, url, headers):
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
assert url == main.settings.update_release_url
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="failed")
|
assert headers["Accept"] == "application/json"
|
||||||
original = main.state.transition_queue_item_if_unclaimed
|
return FakeResponse()
|
||||||
|
|
||||||
def claim_then_transition(*args, **kwargs):
|
monkeypatch.setattr(main.httpx, "Client", FakeClient)
|
||||||
main.state.claim_queue_item(row["id"], "worker", {"failed"})
|
|
||||||
return original(*args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.state, "transition_queue_item_if_unclaimed", claim_then_transition)
|
result = main.check_update_available()
|
||||||
|
|
||||||
try:
|
assert result["status"] == "update_available"
|
||||||
main.queue_item_action(row["id"], main.QueueItemActionRequest(action="retry"))
|
assert result["current_version"] == "0.1.0"
|
||||||
except main.HTTPException as exc:
|
assert result["latest_version"] == "v0.2.0"
|
||||||
assert exc.status_code == 409
|
assert result["update_available"] is True
|
||||||
else:
|
|
||||||
raise AssertionError("expected HTTPException")
|
|
||||||
assert main.state.get_queue_item(row["id"])["claimed_by"] == "worker"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ignore_loses_atomic_race_with_worker_claim(tmp_path, monkeypatch):
|
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
|
||||||
original = main.state.transition_queue_item_if_unclaimed
|
|
||||||
|
|
||||||
def claim_then_transition(*args, **kwargs):
|
|
||||||
main.state.claim_queue_item(row["id"], "worker", {"ready"})
|
|
||||||
return original(*args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.state, "transition_queue_item_if_unclaimed", claim_then_transition)
|
|
||||||
|
|
||||||
try:
|
|
||||||
main.queue_item_action(row["id"], main.QueueItemActionRequest(action="ignore"))
|
|
||||||
except main.HTTPException as exc:
|
|
||||||
assert exc.status_code == 409
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected HTTPException")
|
|
||||||
assert main.state.get_queue_item(row["id"])["claimed_by"] == "worker"
|
|
||||||
|
|
||||||
|
|
||||||
def test_shutdown_timeout_does_not_release_live_worker_claim(tmp_path, monkeypatch):
|
|
||||||
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
source = download / "A.mkv"
|
|
||||||
source.parent.mkdir(parents=True)
|
|
||||||
source.write_bytes(b"a")
|
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id=str(source), source_path=source, name=source.name, state="ready")
|
|
||||||
started = threading.Event()
|
|
||||||
finish = threading.Event()
|
|
||||||
|
|
||||||
class BlockingImporter:
|
|
||||||
def import_file(self, *args, **kwargs):
|
|
||||||
started.set()
|
|
||||||
finish.wait()
|
|
||||||
raise RuntimeError("stopped")
|
|
||||||
|
|
||||||
def active_worker():
|
|
||||||
claimed = main.state.claim_queue_item(row["id"], main.WORKER_ID, {"ready"})
|
|
||||||
main._import_queue_item(claimed, BlockingImporter(), from_worker=True)
|
|
||||||
|
|
||||||
worker = threading.Thread(target=active_worker)
|
|
||||||
monkeypatch.setattr(main, "_worker_thread", worker)
|
|
||||||
monkeypatch.setattr(main, "WORKER_SHUTDOWN_TIMEOUT_SECONDS", 0.01)
|
|
||||||
worker.start()
|
|
||||||
assert started.wait(1)
|
|
||||||
|
|
||||||
main.shutdown_queue_worker()
|
|
||||||
|
|
||||||
assert worker.is_alive()
|
|
||||||
assert main.state.get_queue_item(row["id"])["claimed_by"] == main.WORKER_ID
|
|
||||||
|
|
||||||
finish.set()
|
|
||||||
worker.join(1)
|
|
||||||
assert not worker.is_alive()
|
|
||||||
assert main.state.get_queue_item(row["id"])["claimed_by"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_manual_sync_preserves_claim_after_source_is_unlinked(tmp_path, monkeypatch):
|
|
||||||
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
batch = download / "Release"
|
|
||||||
batch.mkdir(parents=True)
|
|
||||||
source = batch / "A.mkv"
|
|
||||||
source.write_bytes(b"a")
|
|
||||||
created_batch = main.state.add_manual_batch(batch)
|
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id=str(source), source_path=source, name=source.name, state="ready", batch_id=created_batch["id"])
|
|
||||||
main.state.claim_queue_item(row["id"], main.WORKER_ID, {"ready"})
|
|
||||||
|
|
||||||
source.unlink()
|
|
||||||
main.sync_manual_queue()
|
|
||||||
|
|
||||||
preserved = main.state.get_queue_item(row["id"])
|
|
||||||
assert preserved["state"] == "importing"
|
|
||||||
assert preserved["claimed_by"] == main.WORKER_ID
|
|
||||||
|
|
||||||
|
|
||||||
def test_bulk_sab_import_preserves_job_metadata(tmp_path, monkeypatch):
|
|
||||||
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
release = download / "Release"
|
|
||||||
release.mkdir(parents=True)
|
|
||||||
source = release / "Movie.mkv"
|
|
||||||
source.write_bytes(b"movie")
|
|
||||||
|
|
||||||
class FakeSabnzbdClient:
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def active_nzo_ids(self):
|
|
||||||
return set()
|
|
||||||
|
|
||||||
async def history(self):
|
|
||||||
return {"history": {"slots": [{"nzo_id": "SAB-123", "name": "Release", "category": "manual", "status": "Completed", "storage": str(release)}]}}
|
|
||||||
|
|
||||||
monkeypatch.setattr(main, "SabnzbdClient", FakeSabnzbdClient)
|
|
||||||
|
|
||||||
assert asyncio.run(main._import_ready_sab_jobs(main.Importer(movies, tv))) == 1
|
|
||||||
row = main.state.list_queue_items(active_only=False)[0]
|
|
||||||
assert row["job_id"] == "SAB-123"
|
|
||||||
assert row["sab_category"] == "manual"
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_conflicts_when_item_is_claimed(tmp_path, monkeypatch):
|
|
||||||
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
|
||||||
main.state.claim_queue_item(row["id"], "other-worker", {"ready"})
|
|
||||||
|
|
||||||
try:
|
|
||||||
main.queue_item_action(row["id"], main.QueueItemActionRequest(action="remove"))
|
|
||||||
except main.HTTPException as exc:
|
|
||||||
assert exc.status_code == 409
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected HTTPException")
|
|
||||||
|
|
||||||
|
|
||||||
def test_manual_import_completion_persists_completed_row_and_batch_completion(tmp_path, monkeypatch):
|
|
||||||
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
batch = download / "Release"
|
|
||||||
batch.mkdir(parents=True)
|
|
||||||
source = batch / "Done.mkv"
|
|
||||||
source.write_bytes(b"done")
|
|
||||||
created_batch = main.state.add_manual_batch(batch)
|
|
||||||
|
|
||||||
imported = main._import_manual_batches(main.Importer(movies, tv))
|
|
||||||
main.sync_manual_queue()
|
|
||||||
|
|
||||||
assert imported == 1
|
|
||||||
rows = {row["name"]: row for row in main.state.list_queue_items(active_only=False)}
|
|
||||||
assert rows["Done.mkv"]["state"] == "imported"
|
|
||||||
assert main.state.get_queue_item(rows["Done.mkv"]["id"])["state"] == "imported"
|
|
||||||
batch_row = next(batch for batch in main.state.list_manual_batches() if batch["id"] == created_batch["id"])
|
|
||||||
assert batch_row["status"] == "completed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_worker_failure_stops_retrying_after_limit(tmp_path, monkeypatch):
|
|
||||||
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
|
||||||
batch = download / "Release"
|
|
||||||
batch.mkdir(parents=True)
|
|
||||||
source = batch / "A.mkv"
|
|
||||||
source.write_bytes(b"a")
|
|
||||||
row = main.state.upsert_queue_item(source_type="manual", source_id=str(source), source_path=source, name=source.name, state="ready")
|
|
||||||
|
|
||||||
class BrokenImporter:
|
|
||||||
def import_file(self, *args, **kwargs):
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
for _ in range(main.MAX_RETRY_ATTEMPTS):
|
|
||||||
claimed = main.state.claim_queue_item(row["id"], main.WORKER_ID, {"ready", "retrying"})
|
|
||||||
assert claimed is not None
|
|
||||||
main._import_queue_item(claimed, BrokenImporter(), from_worker=True)
|
|
||||||
current = main.state.get_queue_item(row["id"])
|
|
||||||
if current["state"] == "retrying":
|
|
||||||
main.state.mark_queue_item("manual", current["source_id"], "ready", "retry window elapsed")
|
|
||||||
|
|
||||||
updated = main.state.get_queue_item(row["id"])
|
|
||||||
assert updated["state"] == "failed"
|
|
||||||
assert updated["attempt_count"] == main.MAX_RETRY_ATTEMPTS
|
|
||||||
|
|
||||||
|
|
||||||
def test_control_restart_reports_command_failure(tmp_path, monkeypatch):
|
def test_control_restart_reports_command_failure(tmp_path, monkeypatch):
|
||||||
|
|||||||
@@ -11,69 +11,3 @@ def test_delete_queue_items_by_state_can_target_reason(tmp_path):
|
|||||||
rows = state.list_queue_items()
|
rows = state.list_queue_items()
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0]["source_id"] == "other"
|
assert rows[0]["source_id"] == "other"
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_preserves_terminal_state_by_default(tmp_path):
|
|
||||||
state = State(tmp_path / "state.db")
|
|
||||||
state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="failed", reason="boom")
|
|
||||||
|
|
||||||
updated = state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready", reason="rescanned")
|
|
||||||
|
|
||||||
assert updated["state"] == "failed"
|
|
||||||
assert updated["reason"] == "boom"
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_next_queue_item_marks_importing(tmp_path):
|
|
||||||
state = State(tmp_path / "state.db")
|
|
||||||
state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
|
||||||
|
|
||||||
row = state.claim_next_queue_item("worker-1")
|
|
||||||
|
|
||||||
assert row is not None
|
|
||||||
assert row["state"] == "importing"
|
|
||||||
assert row["claimed_by"] == "worker-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_release_stale_claims_requeues_importing_items(tmp_path):
|
|
||||||
state = State(tmp_path / "state.db")
|
|
||||||
state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
|
||||||
state.claim_next_queue_item("worker-1")
|
|
||||||
|
|
||||||
released = state.release_stale_claims()
|
|
||||||
row = state.get_queue_item(1)
|
|
||||||
|
|
||||||
assert released == 1
|
|
||||||
assert row["state"] == "retrying"
|
|
||||||
assert row["claimed_by"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_claim_queue_item_requires_unclaimed_allowed_state(tmp_path):
|
|
||||||
state = State(tmp_path / "state.db")
|
|
||||||
row = state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
|
||||||
|
|
||||||
claimed = state.claim_queue_item(row["id"], "worker-1", {"ready"})
|
|
||||||
blocked = state.claim_queue_item(row["id"], "worker-2", {"ready", "importing"})
|
|
||||||
|
|
||||||
assert claimed is not None
|
|
||||||
assert claimed["claimed_by"] == "worker-1"
|
|
||||||
assert blocked is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_missing_manual_items_keeps_terminal_rows(tmp_path):
|
|
||||||
state = State(tmp_path / "state.db")
|
|
||||||
state.upsert_queue_item(source_type="manual", source_id="done", name="Done.mkv", state="imported", batch_id=1)
|
|
||||||
state.upsert_queue_item(source_type="manual", source_id="pending", name="Pending.mkv", state="ready", batch_id=1)
|
|
||||||
|
|
||||||
state.remove_missing_manual_items(1, set())
|
|
||||||
|
|
||||||
rows = {row["source_id"]: row for row in state.list_queue_items(active_only=False)}
|
|
||||||
assert "done" in rows
|
|
||||||
assert "pending" not in rows
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_item_respects_next_retry_at(tmp_path):
|
|
||||||
state = State(tmp_path / "state.db")
|
|
||||||
state.upsert_queue_item(source_type="manual", source_id="a", name="A.mkv", state="ready")
|
|
||||||
state.mark_queue_item_result("manual", "a", "retrying", "boom", increment_attempts=True, next_retry_seconds=60)
|
|
||||||
|
|
||||||
assert state.claim_next_queue_item("worker-1") is None
|
|
||||||
|
|||||||
+73
-67
@@ -1,6 +1,3 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_contains_build_info(tmp_path, monkeypatch):
|
def test_health_contains_build_info(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
import importarr.main as main
|
import importarr.main as main
|
||||||
@@ -24,16 +21,6 @@ def test_build_date_is_rendered_in_local_time(monkeypatch):
|
|||||||
assert build_info.build_info()["build_date"] == "2026-07-29T14:00:00+02:00"
|
assert build_info.build_info()["build_date"] == "2026-07-29T14:00:00+02:00"
|
||||||
|
|
||||||
|
|
||||||
def test_build_info_prefers_installed_release_provenance(monkeypatch):
|
|
||||||
import importarr.build_info as build_info
|
|
||||||
|
|
||||||
monkeypatch.setenv("IMPORTARR_VERSION", "v1.2.3")
|
|
||||||
monkeypatch.setenv("IMPORTARR_GIT_SHA", "abc123")
|
|
||||||
|
|
||||||
assert build_info.build_info()["version"] == "v1.2.3"
|
|
||||||
assert build_info.build_info()["git_sha"] == "abc123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_contains_service_configuration(tmp_path, monkeypatch):
|
def test_status_contains_service_configuration(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
import importarr.main as main
|
import importarr.main as main
|
||||||
@@ -46,7 +33,7 @@ def test_status_contains_service_configuration(tmp_path, monkeypatch):
|
|||||||
assert "auth_enabled" in payload
|
assert "auth_enabled" in payload
|
||||||
|
|
||||||
|
|
||||||
def test_index_serves_react_application(tmp_path, monkeypatch):
|
def test_index_renders_queue_controls(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
import importarr.main as main
|
import importarr.main as main
|
||||||
|
|
||||||
@@ -55,31 +42,83 @@ def test_index_serves_react_application(tmp_path, monkeypatch):
|
|||||||
response = TestClient(main.app).get("/")
|
response = TestClient(main.app).get("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert '<div id="root"></div>' in response.text
|
assert "Queue controls" in response.text
|
||||||
assert '/static/assets/app.js' in response.text
|
assert "cancel-current" in response.text
|
||||||
assert '/static/assets/app.css' in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_uses_required_stack_and_capabilities():
|
def test_index_packs_secondary_controls_into_menu(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
|
import importarr.main as main
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
response = TestClient(main.app).get("/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'class="menu"' in response.text
|
||||||
|
assert "Service info and build details" in response.text
|
||||||
|
assert "Queue and history" in response.text
|
||||||
|
assert "Current import" in response.text
|
||||||
|
assert "details.menu[open]" in response.text
|
||||||
|
assert "e.target===e.currentTarget" in response.text
|
||||||
|
assert "function readiness" in response.text
|
||||||
|
assert 'title="${esc(j.reason||j.state)}"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_renders_settings_dialog(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
|
import importarr.main as main
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
response = TestClient(main.app).get("/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "settings-dialog" in response.text
|
||||||
|
assert "SABnzbd" in response.text
|
||||||
|
assert "Radarr" in response.text
|
||||||
|
assert "Sonarr" in response.text
|
||||||
|
assert "Test SABnzbd connection" in response.text
|
||||||
|
assert "Test Radarr connection" in response.text
|
||||||
|
assert "Test Sonarr connection" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_renders_responsive_table_wrappers(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
|
import importarr.main as main
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
response = TestClient(main.app).get("/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'class="table-scroll"' in response.text
|
||||||
|
assert "job-cards" in response.text
|
||||||
|
assert "job-card" in response.text
|
||||||
|
assert '<meta name="viewport" content="width=device-width, initial-scale=1">' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_stylesheet_includes_mobile_responsive_rules():
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
app = Path("frontend/src/main.jsx").read_text()
|
css = Path("importarr/static/importarr.css").read_text()
|
||||||
package = Path("frontend/package.json").read_text()
|
|
||||||
|
|
||||||
assert "lucide-react" in package
|
assert "@media (max-width:640px)" in css
|
||||||
assert "@radix-ui/react-dialog" in package
|
assert ".table-scroll" in css
|
||||||
assert "tailwindcss" in package
|
assert "overflow-x:auto" in css
|
||||||
for endpoint in ("/api/jobs", "/api/settings", "/api/manual-batches", "/api/import/run-now"):
|
assert "flex-direction:column" in css
|
||||||
assert endpoint in app
|
assert ".jobs-table{display:none}" in css
|
||||||
|
assert ".job-cards{display:block}" in css
|
||||||
|
assert "word-break:break-word" in css
|
||||||
def test_frontend_defines_exact_light_and_dark_tokens():
|
assert "max-width:1280px" in css
|
||||||
from pathlib import Path
|
assert ".jobs-table table{table-layout:fixed;min-width:0}" in css
|
||||||
|
assert ".jobs-table th:nth-child(1){width:66%}" in css
|
||||||
css = Path("frontend/src/globals.css").read_text()
|
assert ".row-actions button{width:1.85rem" in css
|
||||||
for value in ("#F8FAFC", "#FFFFFF", "#4A43EC", "#7171FF", "#2AD1ED", "#1A1D2E", "#64748B", "#E2E8F0", "#252B42", "#303753", "#23283B", "#42ECF5", "#8B95B7", "#3D4668"):
|
assert ".summary-strip" in css
|
||||||
assert value in css
|
assert ".menu-panel" in css
|
||||||
assert ".dark" in css
|
assert "@media (prefers-color-scheme:dark)" in css
|
||||||
|
assert "--primary:#4b42b8" in css
|
||||||
|
assert "--cyan:#50dce5" in css
|
||||||
|
|
||||||
|
|
||||||
def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch):
|
def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch):
|
||||||
@@ -145,36 +184,3 @@ def test_connection_test_rejects_unknown_service(tmp_path, monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_control_endpoints_require_configured_bearer_token(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
|
||||||
import importarr.main as main
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.settings, "auth_token", "test-token")
|
|
||||||
client = TestClient(main.app)
|
|
||||||
|
|
||||||
for method, endpoint in (("post", "/api/control/restart"),):
|
|
||||||
assert getattr(client, method)(endpoint).status_code == 401
|
|
||||||
assert getattr(client, method)(endpoint, headers={"Authorization": "Bearer wrong"}).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_endpoint_fails_closed_without_token_on_non_loopback_bind(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
|
||||||
import importarr.main as main
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.settings, "auth_token", None)
|
|
||||||
monkeypatch.setattr(main.settings, "bind_host", "0.0.0.0")
|
|
||||||
assert TestClient(main.app).post("/api/control/restart").status_code == 503
|
|
||||||
|
|
||||||
|
|
||||||
def test_tokenless_local_development_remains_available(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
|
||||||
import importarr.main as main
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.settings, "auth_token", None)
|
|
||||||
monkeypatch.setattr(main.settings, "bind_host", "127.0.0.1")
|
|
||||||
main.require_write_auth()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user