Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5829623a9e | ||
|
|
c2ccb0d4bb | ||
|
|
f9eb633e19 | ||
|
|
ce82a405c5 | ||
|
|
22a1fc5522 | ||
|
|
581934f7b5 | ||
|
|
2ff670a9ae | ||
|
|
57c266dfa8 | ||
|
|
a443909d64 | ||
|
|
f4d151f9fe | ||
|
|
1cafe2b45a | ||
|
|
8cb82ee8c4 | ||
|
|
2f996600c6 | ||
|
|
67335a37b9 | ||
|
|
259f5e7ee2 |
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.db
|
*.db
|
||||||
*.partial
|
*.partial
|
||||||
|
AGENTS.local.md
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
# Importarr Agent Instructions
|
|
||||||
|
|
||||||
Importarr is owned as a Linux-ops-managed service repository. Treat this checkout as the source of truth for application code, deployment files, and local service installs.
|
|
||||||
|
|
||||||
## Repository Source Of Truth
|
|
||||||
|
|
||||||
- Work from `/srv/opencode-workspace/importarr` for Importarr code and deploy changes.
|
|
||||||
- Do not edit host-local legacy scripts as the normal workflow:
|
|
||||||
- `/usr/local/sbin/importarr-status.py`
|
|
||||||
- `/usr/local/sbin/manual-media-import.py`
|
|
||||||
- If an emergency hotfix is made outside the repo, backport it here immediately and reinstall from the repo.
|
|
||||||
|
|
||||||
## Seamless Feature Workflow
|
|
||||||
|
|
||||||
When asked to implement an Importarr feature, fix, UI change, deployment change, or operational behavior change:
|
|
||||||
|
|
||||||
1. Inspect `git status --short --branch` before editing.
|
|
||||||
2. Implement the smallest correct repo change.
|
|
||||||
3. Run the narrowest useful verification, normally:
|
|
||||||
```sh
|
|
||||||
.venv/bin/pytest -q
|
|
||||||
```
|
|
||||||
If shell deploy scripts changed, also run:
|
|
||||||
```sh
|
|
||||||
sh -n deploy/systemd-install.sh && sh -n deploy/repo-upgrade.sh
|
|
||||||
```
|
|
||||||
4. Inspect `git diff` and ensure no secrets, raw `.env`, tokens, databases, or private material are included.
|
|
||||||
5. Commit and push completed Importarr changes by default unless the user explicitly asks not to publish or verification is blocked.
|
|
||||||
6. Install/restart from the repository so the running local service matches the repo:
|
|
||||||
```sh
|
|
||||||
make upgrade-local
|
|
||||||
```
|
|
||||||
7. Verify the live service:
|
|
||||||
```sh
|
|
||||||
make verify-live
|
|
||||||
```
|
|
||||||
8. If the live install fails, inspect `systemctl status importarr.service` and `journalctl -u importarr.service`; fix the repo, commit/push the fix, reinstall, and verify again.
|
|
||||||
|
|
||||||
## Install Model
|
|
||||||
|
|
||||||
- The service virtualenv lives at `/opt/importarr/venv`.
|
|
||||||
- The systemd unit runs `/opt/importarr/venv/bin/importarr` as the `importarr` system user.
|
|
||||||
- The package is installed from the repository into the venv using normal wheel/package install, not editable install.
|
|
||||||
- Do **not** use editable install for the system service: the unprivileged `importarr` user may not be able to read `/srv/opencode-workspace/importarr`, causing `ModuleNotFoundError` at startup.
|
|
||||||
- `/opt/importarr/repo-upgrade.sh` is the pull-and-reinstall helper for machines that should follow pushed `main`.
|
|
||||||
|
|
||||||
## Local Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make test
|
|
||||||
make install-systemd
|
|
||||||
make upgrade-local
|
|
||||||
make repo-upgrade
|
|
||||||
make verify-live
|
|
||||||
```
|
|
||||||
|
|
||||||
`make repo-upgrade` is for pulling already-pushed changes with `git pull --ff-only`. It refuses to run with uncommitted repo changes.
|
|
||||||
|
|
||||||
## Runtime Defaults On This Host
|
|
||||||
|
|
||||||
- Local URL: `http://127.0.0.1:8095/`
|
|
||||||
- Health: `http://127.0.0.1:8095/health`
|
|
||||||
- Status: `http://127.0.0.1:8095/api/status`
|
|
||||||
- Systemd service: `importarr.service`
|
|
||||||
- Env file: `/etc/importarr/importarr.env`
|
|
||||||
- SQLite state: `/var/lib/importarr/importarr.db`
|
|
||||||
|
|
||||||
## Secret Handling
|
|
||||||
|
|
||||||
- Prefer `*_FILE` settings for secrets, for example:
|
|
||||||
- `IMPORTARR_SAB_API_KEY_FILE`
|
|
||||||
- `IMPORTARR_AUTH_TOKEN_FILE`
|
|
||||||
- `IMPORTARR_RADARR_API_KEY_FILE`
|
|
||||||
- `IMPORTARR_SONARR_API_KEY_FILE`
|
|
||||||
- Never commit real env files, API keys, tokens, private keys, service databases, or backup data.
|
|
||||||
- Template files may list variable names with placeholder values or commented examples only.
|
|
||||||
|
|
||||||
## Linux Ops Follow-Through
|
|
||||||
|
|
||||||
For changes that materially alter the live service setup, ports, routes, monitoring, backup coverage, or host ownership, also follow the linux-ops documentation/systems-overview update rules. Do not mix unrelated pre-existing uncommitted changes from `linux-ops-docs` or `systems-overview` into Importarr commits.
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
PYTHON ?= .venv/bin/python
|
PYTHON ?= .venv/bin/python
|
||||||
PIP ?= .venv/bin/pip
|
PIP ?= .venv/bin/pip
|
||||||
SERVICE ?= importarr.service
|
SERVICE ?= importarr.service
|
||||||
LIVE_URL ?= http://127.0.0.1:8095
|
IMPORTARR_PREFIX ?= /opt/importarr
|
||||||
|
IMPORTARR_REPO_DIR ?= $(CURDIR)
|
||||||
|
IMPORTARR_URL ?= http://127.0.0.1:8765
|
||||||
|
|
||||||
.PHONY: test install-systemd install-from-repo upgrade-local repo-upgrade verify-live
|
.PHONY: test install-systemd install-from-repo repo-upgrade verify
|
||||||
|
|
||||||
test:
|
test:
|
||||||
$(PYTHON) -m pytest
|
$(PYTHON) -m pytest
|
||||||
@@ -12,16 +14,12 @@ install-systemd:
|
|||||||
sudo -n sh deploy/systemd-install.sh
|
sudo -n sh deploy/systemd-install.sh
|
||||||
|
|
||||||
install-from-repo:
|
install-from-repo:
|
||||||
sudo -n /opt/importarr/venv/bin/pip install --upgrade /srv/opencode-workspace/importarr
|
sudo -n $(IMPORTARR_PREFIX)/venv/bin/pip install --upgrade $(IMPORTARR_REPO_DIR)
|
||||||
|
|
||||||
upgrade-local:
|
|
||||||
sudo -n /opt/importarr/venv/bin/pip install --upgrade /srv/opencode-workspace/importarr
|
|
||||||
sudo -n systemctl restart $(SERVICE)
|
|
||||||
|
|
||||||
repo-upgrade:
|
repo-upgrade:
|
||||||
sudo -n sh /opt/importarr/repo-upgrade.sh
|
sudo -n IMPORTARR_PREFIX=$(IMPORTARR_PREFIX) IMPORTARR_REPO_DIR=$(IMPORTARR_REPO_DIR) sh deploy/repo-upgrade.sh
|
||||||
|
|
||||||
verify-live:
|
verify:
|
||||||
curl -fsS $(LIVE_URL)/health
|
curl -fsS $(IMPORTARR_URL)/health
|
||||||
curl -fsS $(LIVE_URL)/api/status
|
curl -fsS $(IMPORTARR_URL)/api/status
|
||||||
curl -fsS $(LIVE_URL)/api/preview
|
curl -fsS $(IMPORTARR_URL)/api/preview
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
Importarr is an Arr-style service for importing manually categorized SABnzbd downloads after SAB reports final completion. It owns one SAB category, defaults to `manual`, and refuses to import transient Direct Unpack paths or jobs still in SAB queue/post-processing.
|
Importarr is an Arr-style service for importing manually categorized SABnzbd downloads after SAB reports final completion. It owns one SAB category, defaults to `manual`, and refuses to import transient Direct Unpack paths or jobs still in SAB queue/post-processing.
|
||||||
|
|
||||||
## New-machine install
|
## Install
|
||||||
|
|
||||||
Importarr is intended to feel like a small Arr service: deploy the container or systemd service, edit one env file, point SABnzbd category `manual` at the same completed-download path, then open the web UI.
|
Importarr is intended to feel like a small Arr service: deploy the container or systemd service, edit one env file, point SABnzbd category `manual` at the same completed-download path, then open the web UI.
|
||||||
|
|
||||||
### Docker Compose, recommended
|
### Docker Compose, recommended
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir -p /opt/importarr/config
|
mkdir -p importarr/config
|
||||||
cd /opt/importarr
|
cd importarr
|
||||||
curl -fsSLO https://gitea.delphas.dk/daniels/importarr/raw/branch/main/deploy/docker-compose.example.yml
|
curl -fsSLO https://example.com/importarr/deploy/docker-compose.example.yml
|
||||||
curl -fsSLo importarr.env https://gitea.delphas.dk/daniels/importarr/raw/branch/main/deploy/importarr.env.example
|
curl -fsSLo importarr.env https://example.com/importarr/deploy/importarr.env.example
|
||||||
${EDITOR:-vi} importarr.env
|
${EDITOR:-vi} importarr.env
|
||||||
docker compose -f docker-compose.example.yml --env-file importarr.env up -d
|
docker compose -f docker-compose.example.yml --env-file importarr.env up -d
|
||||||
```
|
```
|
||||||
@@ -26,25 +26,14 @@ docker build -t importarr:local .
|
|||||||
### systemd / pip install
|
### systemd / pip install
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://gitea.delphas.dk/daniels/importarr.git
|
git clone https://example.com/importarr.git
|
||||||
cd importarr
|
cd importarr
|
||||||
sudo sh deploy/systemd-install.sh
|
sudo sh deploy/systemd-install.sh
|
||||||
sudo ${EDITOR:-vi} /etc/importarr/importarr.env
|
sudo ${EDITOR:-vi} /etc/importarr/importarr.env
|
||||||
sudo systemctl start importarr.service
|
sudo systemctl start importarr.service
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer creates the `importarr` system user when needed, installs a virtualenv at `/opt/importarr/venv`, and installs the package from the checked-out repository. The repository is therefore the source of truth: pull or edit the repo, reinstall/restart from the repo, and the service runs the package built from that code.
|
The installer creates the `importarr` system user when needed, installs a virtualenv, and installs the package from the checked-out repository. Override install paths with `IMPORTARR_*` variables if the defaults do not fit your environment.
|
||||||
|
|
||||||
For local upgrades from a checked-out repo on dgsserver1, use the repo workflow instead of editing live scripts:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd /srv/opencode-workspace/importarr
|
|
||||||
.venv/bin/python -m pytest
|
|
||||||
git status --short --branch
|
|
||||||
sudo -n /opt/importarr/venv/bin/pip install --upgrade /srv/opencode-workspace/importarr
|
|
||||||
sudo -n systemctl restart importarr.service
|
|
||||||
make verify-live
|
|
||||||
```
|
|
||||||
|
|
||||||
For a machine that should stay current with the repository, use the installed repo-upgrade helper:
|
For a machine that should stay current with the repository, use the installed repo-upgrade helper:
|
||||||
|
|
||||||
@@ -54,7 +43,7 @@ sudo -n sh /opt/importarr/repo-upgrade.sh
|
|||||||
|
|
||||||
The helper refuses to run when the checkout has uncommitted changes, then performs `git pull --ff-only`, reinstalls the package from the repo, restarts `importarr.service`, and prints service status. Use it after changes have been committed and pushed to `main`.
|
The helper 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`.
|
||||||
|
|
||||||
Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.0`, ...), pushed with tags, then reinstalled from the tagged checkout or artifact. Do not hand-edit `/usr/local/sbin/importarr-status.py` or `/usr/local/sbin/manual-media-import.py` except for a documented emergency hotfix that is immediately backported here.
|
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.
|
||||||
|
|
||||||
### Required setup
|
### Required setup
|
||||||
|
|
||||||
@@ -82,6 +71,11 @@ Release-worthy changes should be committed, tagged with SemVer (`v0.1.1`, `v0.2.
|
|||||||
- `GET /api/manual-batches`
|
- `GET /api/manual-batches`
|
||||||
- `POST /api/manual-batches` with `{ "path": "relative/or/absolute/path" }`
|
- `POST /api/manual-batches` with `{ "path": "relative/or/absolute/path" }`
|
||||||
- `DELETE /api/manual-batches/{id}`
|
- `DELETE /api/manual-batches/{id}`
|
||||||
|
- `POST /api/control/start`
|
||||||
|
- `POST /api/control/pause`
|
||||||
|
- `POST /api/control/stop`
|
||||||
|
- `POST /api/control/cancel-current`
|
||||||
|
- `POST /api/queue-items/{id}/action` with `{ "action": "retry|ignore|remove" }`
|
||||||
- `POST /api/import/run-now`
|
- `POST /api/import/run-now`
|
||||||
|
|
||||||
Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` to require `Authorization: Bearer <token>` for write endpoints.
|
Set `IMPORTARR_AUTH_TOKEN_FILE` or `IMPORTARR_AUTH_TOKEN` to require `Authorization: Bearer <token>` for write endpoints.
|
||||||
@@ -96,46 +90,15 @@ pytest
|
|||||||
uvicorn importarr.main:app --reload
|
uvicorn importarr.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
## Migration notes for dgsserver1
|
## Operations
|
||||||
|
|
||||||
Export the existing script settings into `IMPORTARR_*` env vars, add historical folders as explicit manual batches, run a dry-run/inspection through `/api/preview`, then switch the systemd service or Compose route after the ready set matches expectations.
|
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.
|
||||||
|
|
||||||
On dgsserver1, the packaged service is the only intended active entrypoint after cutover. Keep `manual-media-import.timer` disabled unless a repo-managed worker/timer replaces it later.
|
For a systemd install, prefer a normal package install from the checked-out repo over an editable install so the service user does not need read access to your development checkout.
|
||||||
|
|
||||||
## Repository-as-install workflow
|
If the service fails, standard systemd diagnostics are usually enough:
|
||||||
|
|
||||||
Importarr should not drift into host-local scripts. Treat the checked-out repository as the install source:
|
|
||||||
|
|
||||||
1. Make changes in `/srv/opencode-workspace/importarr`.
|
|
||||||
2. Run tests: `make test`.
|
|
||||||
3. Commit and push the repo change.
|
|
||||||
4. Install/restart from the same repo: `make upgrade-local` for local changes, or `make repo-upgrade` to pull the latest pushed `main` and restart.
|
|
||||||
5. Verify the live service: `make verify-live`.
|
|
||||||
|
|
||||||
Do not edit `/usr/local/sbin/importarr-status.py`, `/usr/local/sbin/manual-media-import.py`, or files copied out of the repo as the normal workflow. If an emergency live hotfix is unavoidable, backport it to this repository immediately and run the repo install workflow again.
|
|
||||||
|
|
||||||
### Local install lessons learned
|
|
||||||
|
|
||||||
- The live systemd service runs as the unprivileged `importarr` user.
|
|
||||||
- Do not install the system service with `pip install --editable /srv/opencode-workspace/importarr`; that can fail at startup if the service user cannot read the workspace checkout.
|
|
||||||
- The supported local service install is a normal package install from the repo into `/opt/importarr/venv`:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo -n /opt/importarr/venv/bin/pip install --upgrade /srv/opencode-workspace/importarr
|
sudo systemctl --no-pager --full status importarr.service
|
||||||
sudo -n systemctl restart importarr.service
|
sudo journalctl -u importarr.service -n 120 --no-pager
|
||||||
```
|
|
||||||
|
|
||||||
- `deploy/systemd-install.sh`, `make upgrade-local`, and `/opt/importarr/repo-upgrade.sh` already use this supported model.
|
|
||||||
- After every implementation task that should affect the live local service, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make upgrade-local
|
|
||||||
make verify-live
|
|
||||||
```
|
|
||||||
|
|
||||||
- If `make verify-live` fails, check:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo -n systemctl --no-pager --full status importarr.service
|
|
||||||
sudo -n journalctl -u importarr.service -n 120 --no-pager
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
- "8765:8765"
|
- "8765:8765"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/config
|
- ./config:/config
|
||||||
- /srv/scrypted/sabnzbd-data/downloads/manual:/data/downloads/manual
|
- /path/to/downloads/manual:/data/downloads/manual
|
||||||
- /path/to/movies:/data/movies
|
- /path/to/movies:/data/movies
|
||||||
- /path/to/tv:/data/tv
|
- /path/to/tv:/data/tv
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
IMPORTARR_SAB_URL=http://127.0.0.1:8080
|
IMPORTARR_SAB_URL=http://sabnzbd:8080
|
||||||
# Prefer *_FILE for secrets. Plain env vars still work for local/dev installs.
|
# Prefer *_FILE for secrets. Plain env vars still work for local/dev installs.
|
||||||
# IMPORTARR_SAB_API_KEY=change-me
|
# IMPORTARR_SAB_API_KEY=change-me
|
||||||
# IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key
|
# IMPORTARR_SAB_API_KEY_FILE=/etc/importarr/sab-api-key
|
||||||
IMPORTARR_SAB_CATEGORY=manual
|
IMPORTARR_SAB_CATEGORY=manual
|
||||||
IMPORTARR_DOWNLOAD_ROOT=/srv/scrypted/sabnzbd-data/downloads/manual
|
# SAB may report storage paths from inside its container; set this when that
|
||||||
IMPORTARR_MOVIES_ROOT=/srv/media/movies
|
# differs from the local host path Importarr scans in IMPORTARR_DOWNLOAD_ROOT.
|
||||||
IMPORTARR_TV_ROOT=/srv/media/tv
|
# IMPORTARR_SAB_STORAGE_ROOT=/data/downloads/manual
|
||||||
IMPORTARR_STATE_PATH=/var/lib/importarr/importarr.db
|
IMPORTARR_DOWNLOAD_ROOT=/data/downloads/manual
|
||||||
|
IMPORTARR_MOVIES_ROOT=/data/movies
|
||||||
|
IMPORTARR_TV_ROOT=/data/tv
|
||||||
|
IMPORTARR_STATE_PATH=/config/importarr.db
|
||||||
IMPORTARR_LOG_LEVEL=info
|
IMPORTARR_LOG_LEVEL=info
|
||||||
# IMPORTARR_AUTH_TOKEN=change-me
|
# IMPORTARR_AUTH_TOKEN=change-me
|
||||||
# IMPORTARR_AUTH_TOKEN_FILE=/etc/importarr/auth-token
|
# IMPORTARR_AUTH_TOKEN_FILE=/etc/importarr/auth-token
|
||||||
IMPORTARR_BIND_HOST=0.0.0.0
|
IMPORTARR_BIND_HOST=0.0.0.0
|
||||||
IMPORTARR_BIND_PORT=8095
|
IMPORTARR_BIND_PORT=8765
|
||||||
IMPORTARR_POLL_SECONDS=60
|
IMPORTARR_POLL_SECONDS=60
|
||||||
IMPORTARR_REPO_DIR=/srv/opencode-workspace/importarr
|
# IMPORTARR_REPO_DIR=/path/to/importarr
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Importarr manual media importer
|
Description=Importarr manual media importer web UI
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
EnvironmentFile=/etc/importarr/importarr.env
|
EnvironmentFile=-/etc/importarr/importarr.env
|
||||||
|
EnvironmentFile=-/opt/importarr/build.env
|
||||||
ExecStart=/opt/importarr/venv/bin/importarr
|
ExecStart=/opt/importarr/venv/bin/importarr
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
User=importarr
|
User=root
|
||||||
Group=importarr
|
|
||||||
StateDirectory=importarr
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Critical alert when manual media importer fails
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/logger -t importarr "manual-media-import.service failed; check journalctl -u manual-media-import.service"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Import manual SABnzbd media into Jellyfin library roots
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
OnFailure=manual-media-import-failure.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=-/etc/importarr/importarr.env
|
||||||
|
EnvironmentFile=-/opt/importarr/build.env
|
||||||
|
ExecStart=/opt/importarr/venv/bin/manual-media-import
|
||||||
|
TimeoutStartSec=30min
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run manual media importer periodically
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=5min
|
||||||
|
OnUnitActiveSec=15min
|
||||||
|
AccuracySec=1min
|
||||||
|
Persistent=true
|
||||||
|
Unit=manual-media-import.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+12
-2
@@ -12,9 +12,10 @@ if [ -f "$ENV_FILE" ]; then
|
|||||||
. "$ENV_FILE"
|
. "$ENV_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
REPO_DIR=${IMPORTARR_REPO_DIR:-/srv/opencode-workspace/importarr}
|
PREFIX=${IMPORTARR_PREFIX:-/opt/importarr}
|
||||||
|
REPO_DIR=${IMPORTARR_REPO_DIR:-$(pwd)}
|
||||||
SERVICE=${IMPORTARR_SERVICE:-importarr.service}
|
SERVICE=${IMPORTARR_SERVICE:-importarr.service}
|
||||||
VENV=${IMPORTARR_VENV:-/opt/importarr/venv}
|
VENV=${IMPORTARR_VENV:-$PREFIX/venv}
|
||||||
|
|
||||||
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
|
||||||
@@ -31,5 +32,14 @@ fi
|
|||||||
git fetch --prune origin
|
git fetch --prune origin
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
"$VENV/bin/pip" install --upgrade "$REPO_DIR"
|
"$VENV/bin/pip" install --upgrade "$REPO_DIR"
|
||||||
|
install -m 0644 "$REPO_DIR/deploy/importarr.service" /etc/systemd/system/importarr.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || printf development)"
|
||||||
|
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
cat > "$PREFIX/build.env" <<EOF
|
||||||
|
IMPORTARR_GIT_SHA=$GIT_SHA
|
||||||
|
IMPORTARR_BUILD_DATE=$BUILD_DATE
|
||||||
|
EOF
|
||||||
systemctl restart "$SERVICE"
|
systemctl restart "$SERVICE"
|
||||||
|
systemctl restart manual-media-import.timer
|
||||||
systemctl --no-pager --full status "$SERVICE"
|
systemctl --no-pager --full status "$SERVICE"
|
||||||
|
|||||||
@@ -6,13 +6,9 @@ if [ "$(id -u)" -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
install -d -m 0755 /etc/importarr /var/lib/importarr
|
install -d -m 0755 /etc/importarr /var/lib/importarr /run/manual-media-import
|
||||||
install -d -m 0755 /opt/importarr
|
install -d -m 0755 /opt/importarr
|
||||||
REPO_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
REPO_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
if ! id importarr >/dev/null 2>&1; then
|
|
||||||
useradd --system --home /var/lib/importarr --shell /usr/sbin/nologin importarr
|
|
||||||
fi
|
|
||||||
chown importarr:importarr /var/lib/importarr
|
|
||||||
python3 -m venv /opt/importarr/venv
|
python3 -m venv /opt/importarr/venv
|
||||||
/opt/importarr/venv/bin/pip install --upgrade pip
|
/opt/importarr/venv/bin/pip install --upgrade pip
|
||||||
/opt/importarr/venv/bin/pip install --upgrade "$REPO_DIR"
|
/opt/importarr/venv/bin/pip install --upgrade "$REPO_DIR"
|
||||||
@@ -24,7 +20,19 @@ if ! grep -q '^IMPORTARR_REPO_DIR=' /etc/importarr/importarr.env; then
|
|||||||
printf '\nIMPORTARR_REPO_DIR=%s\n' "$REPO_DIR" >> /etc/importarr/importarr.env
|
printf '\nIMPORTARR_REPO_DIR=%s\n' "$REPO_DIR" >> /etc/importarr/importarr.env
|
||||||
fi
|
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.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 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
|
||||||
|
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)"
|
||||||
|
cat > /opt/importarr/build.env <<EOF
|
||||||
|
IMPORTARR_GIT_SHA=$GIT_SHA
|
||||||
|
IMPORTARR_BUILD_DATE=$BUILD_DATE
|
||||||
|
EOF
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable importarr.service
|
systemctl enable importarr.service
|
||||||
echo "Edit /etc/importarr/importarr.env, then run: systemctl start importarr.service"
|
systemctl enable manual-media-import.timer
|
||||||
|
systemctl restart importarr.service
|
||||||
|
systemctl start manual-media-import.timer
|
||||||
|
echo "Importarr installed from $REPO_DIR. Future upgrades: sudo -n sh /opt/importarr/repo-upgrade.sh"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class Settings(BaseModel):
|
|||||||
sab_url: str = "http://sabnzbd:8080"
|
sab_url: str = "http://sabnzbd:8080"
|
||||||
sab_api_key: str | None = None
|
sab_api_key: str | None = None
|
||||||
sab_category: str = "manual"
|
sab_category: str = "manual"
|
||||||
|
sab_storage_root: Path | None = None
|
||||||
download_root: Path = Path("/data/downloads/manual")
|
download_root: Path = Path("/data/downloads/manual")
|
||||||
movies_root: Path = Path("/data/movies")
|
movies_root: Path = Path("/data/movies")
|
||||||
tv_root: Path = Path("/data/tv")
|
tv_root: Path = Path("/data/tv")
|
||||||
@@ -30,6 +31,7 @@ class Settings(BaseModel):
|
|||||||
sab_url=os.getenv("IMPORTARR_SAB_URL", cls.model_fields["sab_url"].default),
|
sab_url=os.getenv("IMPORTARR_SAB_URL", cls.model_fields["sab_url"].default),
|
||||||
sab_api_key=_env_secret("IMPORTARR_SAB_API_KEY"),
|
sab_api_key=_env_secret("IMPORTARR_SAB_API_KEY"),
|
||||||
sab_category=os.getenv("IMPORTARR_SAB_CATEGORY", "manual"),
|
sab_category=os.getenv("IMPORTARR_SAB_CATEGORY", "manual"),
|
||||||
|
sab_storage_root=Path(os.getenv("IMPORTARR_SAB_STORAGE_ROOT")) if os.getenv("IMPORTARR_SAB_STORAGE_ROOT") else None,
|
||||||
download_root=Path(os.getenv("IMPORTARR_DOWNLOAD_ROOT", "/data/downloads/manual")),
|
download_root=Path(os.getenv("IMPORTARR_DOWNLOAD_ROOT", "/data/downloads/manual")),
|
||||||
movies_root=Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies")),
|
movies_root=Path(os.getenv("IMPORTARR_MOVIES_ROOT", "/data/movies")),
|
||||||
tv_root=Path(os.getenv("IMPORTARR_TV_ROOT", "/data/tv")),
|
tv_root=Path(os.getenv("IMPORTARR_TV_ROOT", "/data/tv")),
|
||||||
|
|||||||
+20
-5
@@ -4,6 +4,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -13,6 +14,10 @@ class ImportResult:
|
|||||||
bytes: int
|
bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
class ImportCancelled(Exception):
|
||||||
|
"""Raised when an import is cancelled at a safe copy boundary."""
|
||||||
|
|
||||||
|
|
||||||
class Importer:
|
class Importer:
|
||||||
def __init__(self, movies_root: Path, tv_root: Path):
|
def __init__(self, movies_root: Path, tv_root: Path):
|
||||||
self.movies_root = movies_root
|
self.movies_root = movies_root
|
||||||
@@ -22,14 +27,24 @@ 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) -> 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")
|
||||||
with source.open("rb") as src, partial.open("wb") as dst:
|
try:
|
||||||
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
with source.open("rb") as src, partial.open("wb") as dst:
|
||||||
dst.flush()
|
while True:
|
||||||
os.fsync(dst.fileno())
|
if should_cancel and should_cancel():
|
||||||
|
raise ImportCancelled("import cancelled")
|
||||||
|
chunk = src.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
dst.write(chunk)
|
||||||
|
dst.flush()
|
||||||
|
os.fsync(dst.fileno())
|
||||||
|
except ImportCancelled:
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
if partial.stat().st_size != source.stat().st_size:
|
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)
|
||||||
|
|||||||
+312
-26
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import httpx
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -12,7 +13,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from .build_info import build_info
|
from .build_info import build_info
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
from .importer import Importer
|
from .importer import ImportCancelled, Importer
|
||||||
from .sabnzbd import SabnzbdClient
|
from .sabnzbd import SabnzbdClient
|
||||||
from .readiness import classify_history_item
|
from .readiness import classify_history_item
|
||||||
from .scanner import scan_videos
|
from .scanner import scan_videos
|
||||||
@@ -33,6 +34,39 @@ class RunNowRequest(BaseModel):
|
|||||||
force: bool = False
|
force: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class QueueControlRequest(BaseModel):
|
||||||
|
mode: str
|
||||||
|
|
||||||
|
|
||||||
|
class QueueItemActionRequest(BaseModel):
|
||||||
|
action: str
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettingsUpdate(BaseModel):
|
||||||
|
sab_url: str
|
||||||
|
sab_api_key: str | None = None
|
||||||
|
radarr_url: str | None = None
|
||||||
|
radarr_api_key: str | None = None
|
||||||
|
sonarr_url: str | None = None
|
||||||
|
sonarr_api_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestRequest(BaseModel):
|
||||||
|
service: str
|
||||||
|
url: str
|
||||||
|
api_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_ui_settings() -> None:
|
||||||
|
for key in ("sab_url", "sab_api_key", "radarr_url", "radarr_api_key", "sonarr_url", "sonarr_api_key"):
|
||||||
|
stored = state.get_app_state(key)
|
||||||
|
if stored is not None:
|
||||||
|
setattr(settings, key, stored or None)
|
||||||
|
|
||||||
|
|
||||||
|
load_ui_settings()
|
||||||
|
|
||||||
|
|
||||||
def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
def require_write_auth(authorization: Annotated[str | None, Header()] = None) -> None:
|
||||||
if not settings.auth_token:
|
if not settings.auth_token:
|
||||||
return
|
return
|
||||||
@@ -47,12 +81,13 @@ def health() -> dict[str, str]:
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request) -> HTMLResponse:
|
def index(request: Request) -> HTMLResponse:
|
||||||
return templates.TemplateResponse("index.html", {"request": request, "status": status(), "batches": state.list_manual_batches()})
|
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()
|
||||||
return {
|
return {
|
||||||
"app": "Importarr",
|
"app": "Importarr",
|
||||||
"build": build_info(),
|
"build": build_info(),
|
||||||
@@ -61,18 +96,150 @@ def status() -> dict[str, object]:
|
|||||||
"movies_root": str(settings.movies_root),
|
"movies_root": str(settings.movies_root),
|
||||||
"tv_root": str(settings.tv_root),
|
"tv_root": str(settings.tv_root),
|
||||||
"sab_url": settings.sab_url,
|
"sab_url": settings.sab_url,
|
||||||
|
"sab_api_key_configured": bool(settings.sab_api_key),
|
||||||
|
"radarr_url": settings.radarr_url or "",
|
||||||
|
"radarr_api_key_configured": bool(settings.radarr_api_key),
|
||||||
|
"sonarr_url": settings.sonarr_url or "",
|
||||||
|
"sonarr_api_key_configured": bool(settings.sonarr_api_key),
|
||||||
"auth_enabled": bool(settings.auth_token),
|
"auth_enabled": bool(settings.auth_token),
|
||||||
"bind": f"{settings.bind_host}:{settings.bind_port}",
|
"bind": f"{settings.bind_host}:{settings.bind_port}",
|
||||||
"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"),
|
||||||
"current": None,
|
"current": control["current"],
|
||||||
|
"control": control,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/settings")
|
||||||
|
def get_ui_settings() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"sab_url": settings.sab_url,
|
||||||
|
"sab_api_key_configured": bool(settings.sab_api_key),
|
||||||
|
"radarr_url": settings.radarr_url or "",
|
||||||
|
"radarr_api_key_configured": bool(settings.radarr_api_key),
|
||||||
|
"sonarr_url": settings.sonarr_url or "",
|
||||||
|
"sonarr_api_key_configured": bool(settings.sonarr_api_key),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/settings")
|
||||||
|
def update_ui_settings(payload: AppSettingsUpdate, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
sab_url = payload.sab_url.strip()
|
||||||
|
if not sab_url:
|
||||||
|
raise HTTPException(status_code=400, detail="SAB URL is required")
|
||||||
|
values = {
|
||||||
|
"sab_url": sab_url,
|
||||||
|
"sab_api_key": (payload.sab_api_key or "").strip(),
|
||||||
|
"radarr_url": (payload.radarr_url or "").strip(),
|
||||||
|
"radarr_api_key": (payload.radarr_api_key or "").strip(),
|
||||||
|
"sonarr_url": (payload.sonarr_url or "").strip(),
|
||||||
|
"sonarr_api_key": (payload.sonarr_api_key or "").strip(),
|
||||||
|
}
|
||||||
|
for key, value in values.items():
|
||||||
|
state.set_app_state(key, value)
|
||||||
|
setattr(settings, key, value or None)
|
||||||
|
settings.sab_url = sab_url
|
||||||
|
return get_ui_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/settings/test-connection")
|
||||||
|
async def test_connection(payload: ConnectionTestRequest, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
service = payload.service.strip().lower()
|
||||||
|
url = payload.url.strip().rstrip("/")
|
||||||
|
api_key = (payload.api_key or "").strip() or None
|
||||||
|
if service not in {"sabnzbd", "radarr", "sonarr"}:
|
||||||
|
raise HTTPException(status_code=400, detail="service must be sabnzbd, radarr, or sonarr")
|
||||||
|
if not url:
|
||||||
|
raise HTTPException(status_code=400, detail="URL is required")
|
||||||
|
if api_key is None:
|
||||||
|
api_key = getattr(settings, f"{service if service != 'sabnzbd' else 'sab'}_api_key")
|
||||||
|
try:
|
||||||
|
if service == "sabnzbd":
|
||||||
|
data = await SabnzbdClient(url, api_key).queue()
|
||||||
|
return {"ok": True, "service": service, "message": f"Connected to SABnzbd; {len(data.get('queue', {}).get('slots', []))} queued jobs visible."}
|
||||||
|
headers = {"X-Api-Key": api_key} if api_key else {}
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
response = await client.get(f"{url}/api/v3/system/status", headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
name = str(data.get("appName") or service.title())
|
||||||
|
version = str(data.get("version") or "unknown version")
|
||||||
|
return {"ok": True, "service": service, "message": f"Connected to {name} {version}."}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "service": service, "message": f"Connection failed: {exc.__class__.__name__}"}
|
||||||
|
|
||||||
|
|
||||||
|
def control_status() -> dict[str, object]:
|
||||||
|
mode = state.get_app_state("queue_mode", "running") or "running"
|
||||||
|
current = state.get_app_state("current_job")
|
||||||
|
cancel_requested = (state.get_app_state("cancel_requested", "false") or "false") == "true"
|
||||||
|
return {
|
||||||
|
"queue_mode": mode,
|
||||||
|
"queue_accepting_new_jobs": mode == "running",
|
||||||
|
"cancel_requested": cancel_requested,
|
||||||
|
"current": current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def queue_accepting_new_jobs() -> bool:
|
||||||
|
return (state.get_app_state("queue_mode", "running") or "running") == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_requested() -> bool:
|
||||||
|
return (state.get_app_state("cancel_requested", "false") or "false") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def consume_cancel_request() -> bool:
|
||||||
|
if not cancel_requested():
|
||||||
|
return False
|
||||||
|
state.set_app_state("cancel_requested", "false")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_job(name: str | None) -> None:
|
||||||
|
state.set_app_state("current_job", name or "")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/control/queue")
|
||||||
|
def set_queue_control(payload: QueueControlRequest, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
if payload.mode not in {"running", "paused", "stopped"}:
|
||||||
|
raise HTTPException(status_code=400, detail="mode must be running, paused, or stopped")
|
||||||
|
state.set_app_state("queue_mode", payload.mode)
|
||||||
|
if payload.mode == "running":
|
||||||
|
state.set_app_state("cancel_requested", "false")
|
||||||
|
return control_status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/control/start")
|
||||||
|
def start_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
state.set_app_state("queue_mode", "running")
|
||||||
|
state.set_app_state("cancel_requested", "false")
|
||||||
|
return control_status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/control/pause")
|
||||||
|
def pause_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
state.set_app_state("queue_mode", "paused")
|
||||||
|
return control_status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/control/stop")
|
||||||
|
def stop_queue(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
state.set_app_state("queue_mode", "stopped")
|
||||||
|
return control_status()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/control/cancel-current")
|
||||||
|
def cancel_current(_: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
state.set_app_state("cancel_requested", "true")
|
||||||
|
return control_status()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/manual-batches")
|
@app.get("/api/manual-batches")
|
||||||
def manual_batches() -> list[dict[str, object]]:
|
def manual_batches() -> list[dict[str, object]]:
|
||||||
sync_manual_queue()
|
if queue_accepting_new_jobs():
|
||||||
|
sync_manual_queue()
|
||||||
rows = []
|
rows = []
|
||||||
for batch in state.list_manual_batches():
|
for batch in state.list_manual_batches():
|
||||||
videos = [item for item in state.list_queue_items() if item["batch_id"] == batch["id"]] if batch["status"] == "active" else []
|
videos = [item for item in state.list_queue_items() if item["batch_id"] == batch["id"]] if batch["status"] == "active" else []
|
||||||
@@ -100,6 +267,29 @@ def history() -> list[dict[str, object]]:
|
|||||||
return state.list_history()
|
return state.list_history()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/queue-items/{item_id}/action")
|
||||||
|
def queue_item_action(item_id: int, payload: QueueItemActionRequest, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||||
|
item = state.get_queue_item(item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="queue item not found")
|
||||||
|
if payload.action == "retry":
|
||||||
|
retry_state = "manual_batch" if item["source_type"] == "manual" else "ready"
|
||||||
|
state.mark_queue_item(item["source_type"], item["source_id"], retry_state, "retry requested")
|
||||||
|
elif payload.action == "run-now":
|
||||||
|
imported = _import_queue_item(item, Importer(settings.movies_root, settings.tv_root))
|
||||||
|
updated = state.get_queue_item(item_id)
|
||||||
|
return {"status": "imported" if imported else "updated", "imported": imported, "item": serialize_queue_item(updated or item)}
|
||||||
|
elif payload.action == "ignore":
|
||||||
|
state.mark_queue_item(item["source_type"], item["source_id"], "skipped", "ignored by user")
|
||||||
|
elif payload.action == "remove":
|
||||||
|
state.delete_queue_item(item_id)
|
||||||
|
return {"status": "removed", "id": item_id}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="action must be retry, ignore, or remove")
|
||||||
|
updated = state.get_queue_item(item_id)
|
||||||
|
return {"status": "updated", "item": serialize_queue_item(updated or item)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/jobs")
|
@app.get("/api/jobs")
|
||||||
async def jobs() -> dict[str, object]:
|
async def jobs() -> dict[str, object]:
|
||||||
return await preview()
|
return await preview()
|
||||||
@@ -107,9 +297,10 @@ async def jobs() -> dict[str, object]:
|
|||||||
|
|
||||||
@app.get("/api/preview")
|
@app.get("/api/preview")
|
||||||
async def preview() -> dict[str, object]:
|
async def preview() -> dict[str, object]:
|
||||||
await sync_queue()
|
if queue_accepting_new_jobs():
|
||||||
|
await sync_queue()
|
||||||
jobs = queue_jobs()
|
jobs = queue_jobs()
|
||||||
return {"sab_status": "ok", "jobs": jobs, "would_import": sum(1 for row in jobs if row["state"] in {"ready", "manual_batch"})}
|
return {"sab_status": "ok", "jobs": jobs, "groups": group_jobs(jobs), "would_import": sum(1 for row in jobs if row["state"] in {"ready", "manual_batch"}), "control": control_status()}
|
||||||
|
|
||||||
|
|
||||||
async def sync_queue() -> None:
|
async def sync_queue() -> None:
|
||||||
@@ -122,32 +313,78 @@ 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)
|
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 "")
|
||||||
if not job_id:
|
if not job_id:
|
||||||
continue
|
continue
|
||||||
if readiness.ready and readiness.storage:
|
if readiness.ready and readiness.storage:
|
||||||
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)
|
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:
|
||||||
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)
|
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 ""))
|
||||||
|
|
||||||
|
|
||||||
def queue_jobs() -> list[dict[str, object]]:
|
def queue_jobs() -> list[dict[str, object]]:
|
||||||
return [
|
return [serialize_queue_item(item) for item in state.list_queue_items(active_only=False) if item["source_type"] != "system"]
|
||||||
{
|
|
||||||
"name": item["name"],
|
|
||||||
"state": item["state"],
|
def serialize_queue_item(item: dict[str, object]) -> dict[str, object]:
|
||||||
"reason": item["reason"],
|
state_name = str(item["state"])
|
||||||
"relative_path": item["relative_path"],
|
source_type = str(item["source_type"])
|
||||||
"storage": item["source_path"],
|
return {
|
||||||
"size": item["size"],
|
"id": item["id"],
|
||||||
"source_type": item["source_type"],
|
"name": item["name"],
|
||||||
}
|
"state": state_name,
|
||||||
for item in state.list_queue_items()
|
"group": job_group(state_name, source_type),
|
||||||
if item["source_type"] != "system"
|
"reason": item["reason"],
|
||||||
]
|
"relative_path": item["relative_path"],
|
||||||
|
"storage": item["source_path"],
|
||||||
|
"size": item["size"],
|
||||||
|
"source_type": source_type,
|
||||||
|
"source_id": item["source_id"],
|
||||||
|
"job_id": item["job_id"],
|
||||||
|
"batch_id": item["batch_id"],
|
||||||
|
"first_seen_at": item["first_seen_at"],
|
||||||
|
"updated_at": item["updated_at"],
|
||||||
|
"completed_at": item["completed_at"],
|
||||||
|
"sab_status": state_name 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", "manual_batch", "failed"},
|
||||||
|
"can_retry": state_name in {"failed", "skipped"},
|
||||||
|
"can_ignore": state_name not in {"imported", "skipped"},
|
||||||
|
"can_remove": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def job_group(state_name: str, source_type: str) -> str:
|
||||||
|
if source_type == "manual":
|
||||||
|
return "manual_batch"
|
||||||
|
if state_name == "ready":
|
||||||
|
return "ready"
|
||||||
|
if state_name in {"importing", "copying"}:
|
||||||
|
return "importing"
|
||||||
|
if state_name == "failed":
|
||||||
|
return "failed"
|
||||||
|
if state_name == "skipped":
|
||||||
|
return "ignored_category"
|
||||||
|
if state_name == "imported":
|
||||||
|
return "completed"
|
||||||
|
return "sab_processing"
|
||||||
|
|
||||||
|
|
||||||
|
def group_jobs(jobs: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
|
labels = {
|
||||||
|
"sab_processing": "SAB processing",
|
||||||
|
"ready": "Ready",
|
||||||
|
"importing": "Importing",
|
||||||
|
"failed": "Failed",
|
||||||
|
"ignored_category": "Ignored category",
|
||||||
|
"manual_batch": "Manual batch",
|
||||||
|
"completed": "Completed",
|
||||||
|
}
|
||||||
|
return [{"key": key, "label": label, "jobs": [job for job in jobs if job["group"] == key]} for key, label in labels.items()]
|
||||||
|
|
||||||
|
|
||||||
def manual_batch_jobs() -> list[dict[str, object]]:
|
def manual_batch_jobs() -> list[dict[str, object]]:
|
||||||
@@ -156,6 +393,8 @@ def manual_batch_jobs() -> list[dict[str, object]]:
|
|||||||
|
|
||||||
|
|
||||||
def sync_manual_queue() -> None:
|
def sync_manual_queue() -> None:
|
||||||
|
if not queue_accepting_new_jobs():
|
||||||
|
return
|
||||||
root = settings.download_root.resolve()
|
root = settings.download_root.resolve()
|
||||||
for batch in state.list_manual_batches(active_only=True):
|
for batch in state.list_manual_batches(active_only=True):
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -185,37 +424,84 @@ async def _import_ready_sab_jobs(importer: Importer, force: bool = False) -> int
|
|||||||
return 0
|
return 0
|
||||||
imported = 0
|
imported = 0
|
||||||
for item in data.get("history", {}).get("slots", []):
|
for item in data.get("history", {}).get("slots", []):
|
||||||
readiness = classify_history_item(item, active, settings.sab_category, settings.download_root, force_status=force)
|
if consume_cancel_request():
|
||||||
|
break
|
||||||
|
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
|
||||||
for video in scan_videos(readiness.storage):
|
for video in scan_videos(readiness.storage):
|
||||||
|
if consume_cancel_request():
|
||||||
|
return imported
|
||||||
|
set_current_job(str(video.path))
|
||||||
try:
|
try:
|
||||||
result = importer.import_file(video.path)
|
result = importer.import_file(video.path, 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("sab", str(video.path), "imported")
|
state.mark_queue_item("sab", str(video.path), "imported")
|
||||||
imported += 1
|
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:
|
except Exception as exc:
|
||||||
state.add_history(video.path, video.path, "failed", 0, exc.__class__.__name__)
|
state.add_history(video.path, video.path, "failed", 0, exc.__class__.__name__)
|
||||||
state.mark_queue_item("sab", str(video.path), "failed", 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) -> int:
|
||||||
|
if item["source_type"] not in {"sab", "manual"} or item["state"] not in {"ready", "manual_batch", "failed"}:
|
||||||
|
return 0
|
||||||
|
source_path = item.get("source_path")
|
||||||
|
if not source_path:
|
||||||
|
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "failed", "missing source path")
|
||||||
|
return 0
|
||||||
|
source = Path(str(source_path))
|
||||||
|
set_current_job(str(source))
|
||||||
|
try:
|
||||||
|
result = importer.import_file(source, should_cancel=consume_cancel_request)
|
||||||
|
state.add_history(result.source, result.target, "imported", result.bytes)
|
||||||
|
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "imported")
|
||||||
|
return 1
|
||||||
|
except ImportCancelled:
|
||||||
|
state.add_history(source, source, "cancelled", 0, "cancelled")
|
||||||
|
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "skipped", "cancelled")
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
state.add_history(source, source, "failed", 0, exc.__class__.__name__)
|
||||||
|
state.mark_queue_item(str(item["source_type"]), str(item["source_id"]), "failed", exc.__class__.__name__)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
set_current_job(None)
|
||||||
|
|
||||||
|
|
||||||
def _import_manual_batches(importer: Importer) -> int:
|
def _import_manual_batches(importer: Importer) -> int:
|
||||||
sync_manual_queue()
|
if queue_accepting_new_jobs():
|
||||||
|
sync_manual_queue()
|
||||||
imported = 0
|
imported = 0
|
||||||
for batch in state.list_manual_batches(active_only=True):
|
for batch in state.list_manual_batches(active_only=True):
|
||||||
path = Path(batch["path"])
|
path = Path(batch["path"])
|
||||||
items = [item for item in state.list_queue_items() if item["source_type"] == "manual" and item["batch_id"] == batch["id"]]
|
items = [item for item in state.list_queue_items() if item["source_type"] == "manual" and item["batch_id"] == batch["id"]]
|
||||||
for item in items:
|
for item in items:
|
||||||
|
if consume_cancel_request():
|
||||||
|
return imported
|
||||||
source = Path(item["source_path"])
|
source = Path(item["source_path"])
|
||||||
|
set_current_job(str(source))
|
||||||
try:
|
try:
|
||||||
result = importer.import_file(source)
|
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("manual", item["source_id"], "imported")
|
state.mark_queue_item("manual", item["source_id"], "imported")
|
||||||
imported += 1
|
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:
|
except Exception as exc:
|
||||||
state.add_history(source, source, "failed", 0, exc.__class__.__name__)
|
state.add_history(source, source, "failed", 0, exc.__class__.__name__)
|
||||||
state.mark_queue_item("manual", item["source_id"], "failed", 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):
|
if not scan_videos(path):
|
||||||
state.complete_manual_batch(batch["id"])
|
state.complete_manual_batch(batch["id"])
|
||||||
return imported
|
return imported
|
||||||
|
|||||||
+21
-9
@@ -26,25 +26,37 @@ def has_transient_part(path: Path) -> bool:
|
|||||||
return any(part in TRANSIENT_PARTS or any(token in part for token in TRANSIENT_PARTS) for part in path.parts)
|
return any(part in TRANSIENT_PARTS or any(token in part for token in TRANSIENT_PARTS) for part in path.parts)
|
||||||
|
|
||||||
|
|
||||||
def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], category: str, download_root: Path, force_status: bool = False) -> Readiness:
|
def classify_history_item(item: dict[str, Any], active_nzo_ids: set[str], category: str, download_root: Path, force_status: bool = False, sab_storage_root: Path | None = None) -> Readiness:
|
||||||
nzo_id = str(item.get("nzo_id") or item.get("nzoid") or "")
|
nzo_id = str(item.get("nzo_id") or item.get("nzoid") or "")
|
||||||
if not force_status and nzo_id and nzo_id in active_nzo_ids:
|
if not force_status and nzo_id and nzo_id in active_nzo_ids:
|
||||||
return Readiness("processing", "SAB job is still present in queue")
|
return Readiness("processing", "SAB job is still present in queue")
|
||||||
if str(item.get("category") or "") != category:
|
item_category = str(item.get("category") or item.get("cat") or "")
|
||||||
return Readiness("ignored", "SAB category is not owned by Importarr")
|
|
||||||
status = str(item.get("status") or "")
|
status = str(item.get("status") or "")
|
||||||
|
storage_value = str(item.get("storage") or "")
|
||||||
|
storage = Path(storage_value).resolve() if storage_value else None
|
||||||
|
root = download_root.resolve()
|
||||||
|
sab_root = (sab_storage_root or download_root).resolve()
|
||||||
|
storage_in_local_root = bool(storage and (storage == root or root in storage.parents))
|
||||||
|
storage_in_sab_root = bool(storage and (storage == sab_root or sab_root in storage.parents))
|
||||||
|
storage_in_root = storage_in_local_root or storage_in_sab_root
|
||||||
|
if item_category != category and not storage_in_root:
|
||||||
|
return Readiness("ignored", "SAB category/storage is not owned by Importarr", storage)
|
||||||
if not force_status and status == "Failed":
|
if not force_status and status == "Failed":
|
||||||
return Readiness("failed", "SAB history reports failure")
|
return Readiness("failed", "SAB history reports failure")
|
||||||
if not force_status and (status in NOT_READY_STATUSES or status != "Completed"):
|
if not force_status and (status in NOT_READY_STATUSES or status != "Completed"):
|
||||||
return Readiness("processing", f"SAB status is {status or 'unknown'}")
|
return Readiness("processing", f"SAB status is {status or 'unknown'}")
|
||||||
storage_value = str(item.get("storage") or "")
|
if storage is None:
|
||||||
if not storage_value:
|
|
||||||
return Readiness("unknown", "SAB completed item has no final storage")
|
return Readiness("unknown", "SAB completed item has no final storage")
|
||||||
storage = Path(storage_value).resolve()
|
if not storage_in_root:
|
||||||
root = download_root.resolve()
|
|
||||||
if storage != root and root not in storage.parents:
|
|
||||||
return Readiness("ignored", "SAB storage is outside configured download root", storage)
|
return Readiness("ignored", "SAB storage is outside configured download root", storage)
|
||||||
|
if storage_in_sab_root and not storage_in_local_root:
|
||||||
|
storage = root / storage.relative_to(sab_root)
|
||||||
if has_transient_part(storage):
|
if has_transient_part(storage):
|
||||||
return Readiness("processing", "SAB storage path contains transient unpack/admin marker", storage)
|
return Readiness("processing", "SAB storage path contains transient unpack/admin marker", storage)
|
||||||
reason = "forced despite SAB status" if force_status and status != "Completed" else "SAB completed in owned category with final storage"
|
if force_status and status != "Completed":
|
||||||
|
reason = "forced despite SAB status"
|
||||||
|
elif item_category != category:
|
||||||
|
reason = "SAB completed inside Importarr download root"
|
||||||
|
else:
|
||||||
|
reason = "SAB completed in owned category with final storage"
|
||||||
return Readiness("ready", reason, storage)
|
return Readiness("ready", reason, storage)
|
||||||
|
|||||||
+37
-3
@@ -46,6 +46,7 @@ class State:
|
|||||||
size integer not null default 0,
|
size integer not null default 0,
|
||||||
batch_id integer,
|
batch_id integer,
|
||||||
job_id text,
|
job_id text,
|
||||||
|
sab_category 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,
|
||||||
@@ -53,6 +54,20 @@ class State:
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
columns = {row["name"] for row in self.conn.execute("pragma table_info(import_queue_items)")}
|
||||||
|
if "sab_category" not in columns:
|
||||||
|
self.conn.execute("alter table import_queue_items add column sab_category text")
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_app_state(self, key: str, default: str | None = None) -> str | None:
|
||||||
|
row = self.conn.execute("select value from app_state where key = ?", (key,)).fetchone()
|
||||||
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
def set_app_state(self, key: str, value: str) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"insert into app_state(key, value) values (?, ?) on conflict(key) do update set value=excluded.value",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
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]:
|
||||||
@@ -100,11 +115,12 @@ class State:
|
|||||||
size: int = 0,
|
size: int = 0,
|
||||||
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,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
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)
|
insert into import_queue_items(source_type, source_id, source_path, name, state, reason, relative_path, size, batch_id, job_id, sab_category)
|
||||||
values (?,?,?,?,?,?,?,?,?,?)
|
values (?,?,?,?,?,?,?,?,?,?,?)
|
||||||
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,
|
||||||
@@ -114,10 +130,11 @@ class State:
|
|||||||
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,
|
||||||
updated_at=current_timestamp,
|
updated_at=current_timestamp,
|
||||||
completed_at=case when excluded.state in ('imported','failed','skipped') then current_timestamp else null end
|
completed_at=case 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),
|
(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()
|
||||||
@@ -130,6 +147,23 @@ class State:
|
|||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
def delete_queue_item(self, item_id: int) -> bool:
|
||||||
|
cursor = self.conn.execute("delete from import_queue_items where id = ?", (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:
|
||||||
|
if reason is None:
|
||||||
|
cursor = self.conn.execute("delete from import_queue_items where source_type = ? and state = ?", (source_type, state))
|
||||||
|
else:
|
||||||
|
cursor = self.conn.execute("delete from import_queue_items where source_type = ? and state = ? and reason = ?", (source_type, state, reason))
|
||||||
|
self.conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
def get_queue_item(self, item_id: int) -> dict[str, Any] | None:
|
||||||
|
row = self.conn.execute("select * from import_queue_items where id = ?", (item_id,)).fetchone()
|
||||||
|
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:
|
||||||
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 from import_queue_items where source_type='manual' and batch_id=?", (batch_id,)).fetchall()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
body{font-family:system-ui,sans-serif;margin:0;background:#111827;color:#e5e7eb}header,main{max-width:1100px;margin:auto;padding:1rem}.topbar{display:flex;justify-content:space-between;gap:1rem;align-items:center;background:#0f172a}.build{text-align:right}.build strong{font-size:1.2rem}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:1rem}.cards article,.panel{background:#1f2937;border-radius:.75rem;padding:1rem;margin-top:1rem}strong{display:block;font-size:2rem}span,small,dd{color:#9ca3af}table{width:100%;border-collapse:collapse;background:#1f2937;margin-top:1rem}th,td{padding:.6rem;border-bottom:1px solid #374151;text-align:left}input,button{padding:.6rem;border-radius:.4rem;border:1px solid #374151}button{background:#38bdf8;color:#082f49;font-weight:700}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.4rem 1rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:#0f172a;border:1px solid #374151;border-radius:999px;padding:.15rem .5rem}
|
body{font-family:system-ui,sans-serif;margin:0;background:#111827;color:#e5e7eb}header,main{max-width:1100px;margin:auto;padding:1rem}.topbar{display:flex;justify-content:space-between;gap:1rem;align-items:center;background:#0f172a}.build{text-align:right}.build strong{font-size:1.2rem}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:1rem}.cards article,.panel{background:#1f2937;border-radius:.75rem;padding:1rem;margin-top:1rem}strong{display:block;font-size:2rem}span,small,dd{color:#9ca3af}table{width:100%;border-collapse:collapse;background:#1f2937;margin-top:1rem}th,td{padding:.6rem;border-bottom:1px solid #374151;text-align:left;vertical-align:top}input,button{padding:.6rem;border-radius:.4rem;border:1px solid #374151}button{background:#38bdf8;color:#082f49;font-weight:700;cursor:pointer}.danger{background:#f87171;color:#450a0a}.warn{background:#fbbf24;color:#451a03}.controls,.row-actions{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.4rem 1rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:#0f172a;border:1px solid #374151;border-radius:999px;padding:.15rem .5rem;display:inline-block}.section-title{display:flex;align-items:center;justify-content:space-between;gap:1rem}.job-group{margin-top:1.25rem}.job-group h3{display:flex;gap:.5rem;align-items:center}.job-group h3 span{font-size:.9rem;border:1px solid #374151;border-radius:999px;padding:.1rem .45rem}.file-name{font-size:1rem}.row-actions button{padding:.35rem .5rem}td small{display:block;overflow-wrap:anywhere}dialog{background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:.75rem;max-width:min(42rem,90vw)}dialog::backdrop{background:#0009}fieldset{border:1px solid #374151;border-radius:.5rem;margin:1rem 0;padding:1rem}label{display:grid;gap:.35rem;margin:.75rem 0}.hint{color:#9ca3af}output{display:block;margin-top:.5rem;color:#9ca3af}.success{color:#86efac}.error{color:#fca5a5}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div><h1>Importarr</h1><p>Manual SABnzbd imports, safely gated by SAB completion.</p></div>
|
<div><h1>Importarr</h1><p>Manual SABnzbd imports, safely gated by SAB completion.</p></div>
|
||||||
<div class="build"><strong>{{ status.build.version }}</strong><span>{{ status.build.git_sha[:12] }} · {{ status.build.build_date }}</span></div>
|
<div class="build"><strong>{{ status.build.version }}</strong><span>{{ status.build.git_sha[:12] }} · {{ status.build.build_date }}</span><button type="button" id="open-settings">Settings</button></div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section class="cards">
|
<section class="cards">
|
||||||
@@ -28,12 +28,27 @@
|
|||||||
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
|
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
|
||||||
<dt>Python</dt><dd>{{ status.build.python }}</dd>
|
<dt>Python</dt><dd>{{ status.build.python }}</dd>
|
||||||
<dt>SAB URL</dt><dd>{{ status.sab_url }}</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>Download root</dt><dd>{{ status.download_root }}</dd>
|
||||||
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
|
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
|
||||||
<dt>TV root</dt><dd>{{ status.tv_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>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>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Queue controls</h2>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Manual batches</h2>
|
<h2>Manual batches</h2>
|
||||||
<form id="batch-form" class="inline-form">
|
<form id="batch-form" class="inline-form">
|
||||||
@@ -46,16 +61,53 @@
|
|||||||
{% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %}
|
{% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %}
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section class="panel">
|
||||||
<h2>Jobs</h2><button id="force-run" type="button">Force run now</button><div id="jobs">Loading…</div>
|
<div class="section-title"><h2>Jobs and queue</h2><button id="force-run" type="button">Force run now</button></div>
|
||||||
|
<p>Rows are grouped by processing state. Failed and skipped rows can be retried; ignore and remove actions only update Importarr's queue.</p>
|
||||||
|
<div id="jobs">Loading…</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
<script>
|
||||||
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='<table><tr><th>Name</th><th>State</th><th>Context</th></tr>'+d.jobs.map(j=>`<tr><td>${j.name||''}</td><td><span class="state">${j.state}</span></td><td><small>${j.relative_path||j.storage||j.reason||''}</small></td></tr>`).join('')+'</table>'; }
|
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}">Run now</button>`); if(j.can_retry) buttons.push(`<button type="button" data-action="retry" data-id="${j.id}">Retry</button>`); if(j.can_ignore) buttons.push(`<button type="button" data-action="ignore" data-id="${j.id}" class="warn">Ignore</button>`); if(j.can_remove) buttons.push(`<button type="button" data-action="remove" data-id="${j.id}" class="danger">Remove</button>`); return buttons.join(' '); }
|
||||||
|
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><table><thead><tr><th>File</th><th>Release / folder context</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>${esc(j.source_type)}${j.batch_id?' · batch '+esc(j.batch_id):''}</small></td><td><small>${esc(j.relative_path||j.storage||j.source_id)}</small></td><td><span class="state">${esc(j.state)}</span><small>${esc(j.reason||'')}</small></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></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){ document.getElementById('queue-mode').textContent=d.control.queue_mode; document.getElementById('current-job').textContent=d.control.current||'idle'; } }
|
||||||
|
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('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-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(); await fetch('/api/manual-batches',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:e.target.path.value})}); location.reload(); });
|
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('force-run').addEventListener('click', async()=>{ await fetch('/api/import/run-now',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({force:true})}); await refresh(); });
|
document.getElementById('open-settings').addEventListener('click',()=>document.getElementById('settings-dialog').showModal());
|
||||||
|
document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').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);
|
refresh(); setInterval(refresh, 10000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+1093
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ test = ["pytest>=8.2", "pytest-asyncio>=0.23"]
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
importarr = "importarr.main:run"
|
importarr = "importarr.main:run"
|
||||||
|
manual-media-import = "importarr.worker:main"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
from importarr.config import Settings
|
||||||
|
from importarr.state import State
|
||||||
|
|
||||||
|
|
||||||
|
def configure_main(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "initial.db"))
|
||||||
|
import importarr.main as main
|
||||||
|
|
||||||
|
download = tmp_path / "downloads"
|
||||||
|
movies = tmp_path / "movies"
|
||||||
|
tv = tmp_path / "tv"
|
||||||
|
monkeypatch.setattr(main, "settings", Settings(download_root=download, movies_root=movies, tv_root=tv, state_path=tmp_path / "state.db"))
|
||||||
|
monkeypatch.setattr(main, "state", State(tmp_path / "state.db"))
|
||||||
|
return main, download, movies, tv
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_prevents_manual_queue_sync(tmp_path, monkeypatch):
|
||||||
|
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
|
batch = download / "Release"
|
||||||
|
batch.mkdir(parents=True)
|
||||||
|
(batch / "Movie.mkv").write_bytes(b"movie")
|
||||||
|
main.state.add_manual_batch(batch)
|
||||||
|
|
||||||
|
main.state.set_app_state("queue_mode", "paused")
|
||||||
|
main.sync_manual_queue()
|
||||||
|
|
||||||
|
assert main.state.list_queue_items() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_reenables_manual_queue_sync(tmp_path, monkeypatch):
|
||||||
|
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
|
batch = download / "Release"
|
||||||
|
batch.mkdir(parents=True)
|
||||||
|
(batch / "Movie.mkv").write_bytes(b"movie")
|
||||||
|
main.state.add_manual_batch(batch)
|
||||||
|
|
||||||
|
main.state.set_app_state("queue_mode", "paused")
|
||||||
|
main.sync_manual_queue()
|
||||||
|
main.state.set_app_state("queue_mode", "running")
|
||||||
|
main.sync_manual_queue()
|
||||||
|
|
||||||
|
assert len(main.state.list_queue_items()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_jobs_include_groups_and_manual_context(tmp_path, monkeypatch):
|
||||||
|
main, download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
|
batch = download / "Release" / "Season 1"
|
||||||
|
batch.mkdir(parents=True)
|
||||||
|
(batch / "Episode.mkv").write_bytes(b"episode")
|
||||||
|
main.state.add_manual_batch(batch.parent)
|
||||||
|
|
||||||
|
main.sync_manual_queue()
|
||||||
|
jobs = main.queue_jobs()
|
||||||
|
|
||||||
|
assert jobs[0]["group"] == "manual_batch"
|
||||||
|
assert jobs[0]["relative_path"] == "Release/Season 1/Episode.mkv"
|
||||||
|
assert jobs[0]["can_run_now"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_item_retry_ignore_and_remove_actions(tmp_path, monkeypatch):
|
||||||
|
main, _download, _movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
|
row = main.state.upsert_queue_item(source_type="sab", source_id="job-1", name="Release", state="failed", reason="ImportError")
|
||||||
|
|
||||||
|
retried = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="retry"))
|
||||||
|
assert retried["item"]["state"] == "ready"
|
||||||
|
assert retried["item"]["reason"] == "retry requested"
|
||||||
|
|
||||||
|
ignored = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="ignore"))
|
||||||
|
assert ignored["item"]["state"] == "skipped"
|
||||||
|
|
||||||
|
removed = main.queue_item_action(row["id"], main.QueueItemActionRequest(action="remove"))
|
||||||
|
assert removed == {"status": "removed", "id": row["id"]}
|
||||||
|
assert main.state.get_queue_item(row["id"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_item_run_now_imports_only_selected_item(tmp_path, monkeypatch):
|
||||||
|
main, download, movies, _tv = configure_main(tmp_path, monkeypatch)
|
||||||
|
batch = download / "Release"
|
||||||
|
batch.mkdir(parents=True)
|
||||||
|
selected = batch / "Selected.mkv"
|
||||||
|
waiting = batch / "Waiting.mkv"
|
||||||
|
selected.write_bytes(b"selected")
|
||||||
|
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="manual_batch")
|
||||||
|
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"))
|
||||||
|
|
||||||
|
assert result["status"] == "imported"
|
||||||
|
assert result["imported"] == 1
|
||||||
|
assert (movies / "Selected.mkv").read_bytes() == b"selected"
|
||||||
|
assert waiting.exists()
|
||||||
|
rows = {row["name"]: row for row in main.state.list_queue_items(active_only=False)}
|
||||||
|
assert rows["Selected.mkv"]["state"] == "imported"
|
||||||
|
assert rows["Waiting.mkv"]["state"] == "manual_batch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_current_stops_before_next_manual_item(tmp_path, monkeypatch):
|
||||||
|
main, download, movies, tv = configure_main(tmp_path, monkeypatch)
|
||||||
|
batch = download / "Release"
|
||||||
|
batch.mkdir(parents=True)
|
||||||
|
(batch / "A.mkv").write_bytes(b"a")
|
||||||
|
(batch / "B.mkv").write_bytes(b"b")
|
||||||
|
main.state.add_manual_batch(batch)
|
||||||
|
main.sync_manual_queue()
|
||||||
|
main.state.set_app_state("cancel_requested", "true")
|
||||||
|
|
||||||
|
assert main._import_manual_batches(main.Importer(movies, tv)) == 0
|
||||||
|
assert len(main.state.list_queue_items()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_current_stops_active_copy(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" * (1024 * 1024 + 1))
|
||||||
|
main.state.add_manual_batch(batch)
|
||||||
|
main.sync_manual_queue()
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def cancel_during_copy() -> bool:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return calls > 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "consume_cancel_request", cancel_during_copy)
|
||||||
|
|
||||||
|
assert main._import_manual_batches(main.Importer(movies, tv)) == 0
|
||||||
|
assert source.exists()
|
||||||
|
assert not any(movies.glob("*.partial"))
|
||||||
|
assert main.state.list_queue_items(active_only=False)[0]["state"] == "skipped"
|
||||||
@@ -19,9 +19,44 @@ def test_completed_manual_is_ready():
|
|||||||
|
|
||||||
def test_wrong_category_ignored():
|
def test_wrong_category_ignored():
|
||||||
result = classify_history_item(item(category="*"), set(), "manual", ROOT)
|
result = classify_history_item(item(category="*"), set(), "manual", ROOT)
|
||||||
|
assert result.ready
|
||||||
|
assert result.reason == "SAB completed inside Importarr download root"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_category_outside_root_ignored():
|
||||||
|
result = classify_history_item(item(category="*", storage="/tmp/other/Movie"), set(), "manual", ROOT)
|
||||||
assert result.state == "ignored"
|
assert result.state == "ignored"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sab_storage_root_maps_to_local_download_root():
|
||||||
|
result = classify_history_item(
|
||||||
|
item(category="*", storage="/data/downloads/manual/Movie"),
|
||||||
|
set(),
|
||||||
|
"manual",
|
||||||
|
ROOT,
|
||||||
|
sab_storage_root=Path("/data/downloads/manual"),
|
||||||
|
)
|
||||||
|
assert result.ready
|
||||||
|
assert result.storage == ROOT / "Movie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_radarr_sonarr_storage_roots_are_not_importarr_owned():
|
||||||
|
for storage in ("/data/downloads/movies/Movie", "/data/downloads/tv/Show"):
|
||||||
|
result = classify_history_item(
|
||||||
|
item(category="*", storage=storage),
|
||||||
|
set(),
|
||||||
|
"manual",
|
||||||
|
ROOT,
|
||||||
|
sab_storage_root=Path("/data/downloads/manual"),
|
||||||
|
)
|
||||||
|
assert result.state == "ignored"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sab_cat_field_is_treated_as_category():
|
||||||
|
result = classify_history_item(item(category=None, cat="manual"), set(), "manual", ROOT)
|
||||||
|
assert result.ready
|
||||||
|
|
||||||
|
|
||||||
def test_queue_item_not_ready():
|
def test_queue_item_not_ready():
|
||||||
result = classify_history_item(item(), {"1"}, "manual", ROOT)
|
result = classify_history_item(item(), {"1"}, "manual", ROOT)
|
||||||
assert result.state == "processing"
|
assert result.state == "processing"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from importarr.state import State
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_queue_items_by_state_can_target_reason(tmp_path):
|
||||||
|
state = State(tmp_path / "state.db")
|
||||||
|
state.upsert_queue_item(source_type="sab", source_id="stale", name="Stale", state="ignored", reason="SAB category is not owned by Importarr")
|
||||||
|
state.upsert_queue_item(source_type="sab", source_id="other", name="Other", state="ignored", reason="other reason")
|
||||||
|
|
||||||
|
assert state.delete_queue_items_by_state("sab", "ignored", "SAB category is not owned by Importarr") == 1
|
||||||
|
|
||||||
|
rows = state.list_queue_items()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["source_id"] == "other"
|
||||||
@@ -18,3 +18,99 @@ def test_status_contains_service_configuration(tmp_path, monkeypatch):
|
|||||||
assert "movies_root" in payload
|
assert "movies_root" in payload
|
||||||
assert "tv_root" in payload
|
assert "tv_root" in payload
|
||||||
assert "auth_enabled" in payload
|
assert "auth_enabled" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_renders_queue_controls(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 "Queue controls" in response.text
|
||||||
|
assert "cancel-current" 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_settings_endpoint_persists_arr_connection_values(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).post(
|
||||||
|
"/api/settings",
|
||||||
|
json={
|
||||||
|
"sab_url": "http://sab:8080",
|
||||||
|
"sab_api_key": "sab-secret",
|
||||||
|
"radarr_url": "http://radarr:7878",
|
||||||
|
"radarr_api_key": "radarr-secret",
|
||||||
|
"sonarr_url": "http://sonarr:8989",
|
||||||
|
"sonarr_api_key": "sonarr-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"sab_url": "http://sab:8080",
|
||||||
|
"sab_api_key_configured": True,
|
||||||
|
"radarr_url": "http://radarr:7878",
|
||||||
|
"radarr_api_key_configured": True,
|
||||||
|
"sonarr_url": "http://sonarr:8989",
|
||||||
|
"sonarr_api_key_configured": True,
|
||||||
|
}
|
||||||
|
assert main.settings.sab_api_key == "sab-secret"
|
||||||
|
assert main.state.get_app_state("radarr_api_key") == "radarr-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sab_connection_test_reports_success(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||||
|
import importarr.main as main
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
async def fake_queue(self):
|
||||||
|
return {"queue": {"slots": [{"name": "one"}, {"name": "two"}]}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.SabnzbdClient, "queue", fake_queue)
|
||||||
|
|
||||||
|
response = TestClient(main.app).post(
|
||||||
|
"/api/settings/test-connection",
|
||||||
|
json={"service": "sabnzbd", "url": "http://sab:8080", "api_key": "secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"ok": True, "service": "sabnzbd", "message": "Connected to SABnzbd; 2 queued jobs visible."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_test_rejects_unknown_service(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).post(
|
||||||
|
"/api/settings/test-connection",
|
||||||
|
json={"service": "lidarr", "url": "http://lidarr:8686"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|||||||
Reference in New Issue
Block a user