From 6adfd77236096e687fd6b3c83785de83171c9f3e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 16 Feb 2026 11:38:17 -0500 Subject: [PATCH] Add category filtering, default to copy mode, split dev/prod compose - Default MOVE_MODE changed to copy so qBit can continue seeding - Add QBIT_CATEGORY env var (default "Stash") to filter torrents - Split compose into dev (build from source) and prod (pre-built image) - Fix Dockerfile COPY glob needing trailing slash - Update README and summary docs Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- README.md | 25 +++++++++++++++++++------ docker-compose.prod.example.yaml | 22 ++++++++++++++++++++++ docker-compose.yaml | 5 +++-- qbit_client.py | 7 +++++-- stash_handler.py | 5 +++-- summary.md | 10 ++++++---- 7 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 docker-compose.prod.example.yaml diff --git a/Dockerfile b/Dockerfile index 1070c7a..afc46a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,6 @@ FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY *.py . +COPY *.py ./ ENTRYPOINT ["python", "stash_handler.py"] CMD ["daemon"] diff --git a/README.md b/README.md index e04afb2..08dc9c6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # StashHandler -A Docker service that bridges qBittorrent and Stash. It polls qBittorrent for completed torrents, hashes media files, deduplicates them against a SQLite database, and moves new files into Stash's import directory. +A Docker service that bridges qBittorrent and Stash. It polls qBittorrent for completed torrents (filtered by category), hashes media files, deduplicates them against a SQLite database, and copies new files into Stash's import directory — preserving originals so seeding can continue. ## Why @@ -10,18 +10,30 @@ A Docker service that bridges qBittorrent and Stash. It polls qBittorrent for co ## Quick Start -1. Clone this repo -2. Configure environment variables in `docker-compose.yaml` (or via your swarm/stack config) -3. Deploy: +### Development ```bash -# Docker Compose +git clone && cd stashhandler +# Edit environment variables in docker-compose.yaml docker compose up -d --build +``` + +### Production + +```bash +# Copy the example and fill in your values +cp docker-compose.prod.example.yaml docker-compose.yaml +# Edit QBIT_PASSWORD, volume paths, etc. # Docker Swarm docker stack deploy -c docker-compose.yaml stash-handler + +# Or plain Compose +docker compose up -d ``` +The prod compose pulls the pre-built image from `git.fabledsword.com/bvandeusen/stashhandler:latest` and only includes the stash-handler service (qBittorrent and Stash are managed separately). + ## Modes ### Daemon (default) @@ -62,7 +74,8 @@ All configuration is via environment variables. No config files needed — works | `POLL_INTERVAL` | `60` | Seconds between polls | | `MEDIA_EXTENSIONS` | `.mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v` | Comma-separated extensions | | `HASH_ALGORITHM` | `sha256` | `sha256` or `blake3` | -| `MOVE_MODE` | `move` | `move`, `hardlink`, or `copy` | +| `MOVE_MODE` | `copy` | `copy`, `hardlink`, or `move` | +| `QBIT_CATEGORY` | `Stash` | qBit category to filter torrents by | | `LOG_LEVEL` | `INFO` | Python log level | ## Volumes diff --git a/docker-compose.prod.example.yaml b/docker-compose.prod.example.yaml new file mode 100644 index 0000000..d6c9ef4 --- /dev/null +++ b/docker-compose.prod.example.yaml @@ -0,0 +1,22 @@ +services: + stash-handler: + image: git.fabledsword.com/bvandeusen/stashhandler:latest + container_name: stash-handler + restart: unless-stopped + environment: + QBIT_URL: "http://qbittorrent:8080" + QBIT_USERNAME: "admin" + QBIT_PASSWORD: "changeme" + STAGING_DIR: "/staging" + IMPORT_DIR: "/import" + DB_PATH: "/data/seen.db" + POLL_INTERVAL: "300" + MEDIA_EXTENSIONS: ".mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v" + HASH_ALGORITHM: "sha256" + MOVE_MODE: "copy" + QBIT_CATEGORY: "Stash" + LOG_LEVEL: "INFO" + volumes: + - /data/staging:/staging:ro + - /data/stash-import:/import + - /data/stash-handler:/data diff --git a/docker-compose.yaml b/docker-compose.yaml index bbf2676..2d41ad6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,8 +13,9 @@ services: POLL_INTERVAL: "300" MEDIA_EXTENSIONS: ".mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v" HASH_ALGORITHM: "sha256" - MOVE_MODE: "move" - LOG_LEVEL: "INFO" + MOVE_MODE: "copy" + QBIT_CATEGORY: "Stash" + LOG_LEVEL: "DEBUG" volumes: - /data/staging:/staging:ro - /data/stash-import:/import diff --git a/qbit_client.py b/qbit_client.py index ea2a48c..d3f1177 100644 --- a/qbit_client.py +++ b/qbit_client.py @@ -37,9 +37,12 @@ class QBitClient: resp.raise_for_status() return resp - def get_completed_torrents(self): + def get_completed_torrents(self, category=None): """Return a list of completed torrent dicts from qBittorrent.""" - resp = self._request("GET", "/api/v2/torrents/info", params={"filter": "completed"}) + params = {"filter": "completed"} + if category: + params["category"] = category + resp = self._request("GET", "/api/v2/torrents/info", params=params) return resp.json() def get_torrent_files(self, info_hash): diff --git a/stash_handler.py b/stash_handler.py index cc5e07b..cd95070 100644 --- a/stash_handler.py +++ b/stash_handler.py @@ -31,7 +31,8 @@ def load_config(): os.environ.get("MEDIA_EXTENSIONS", DEFAULT_MEDIA_EXTENSIONS).split(",") ), "hash_algorithm": os.environ.get("HASH_ALGORITHM", "sha256"), - "move_mode": os.environ.get("MOVE_MODE", "move"), + "move_mode": os.environ.get("MOVE_MODE", "copy"), + "qbit_category": os.environ.get("QBIT_CATEGORY", "Stash"), "db_path": os.environ.get("DB_PATH", "/data/seen.db"), "log_level": os.environ.get("LOG_LEVEL", "INFO"), } @@ -98,7 +99,7 @@ def daemon(cfg): while running: try: - torrents = qbit.get_completed_torrents() + torrents = qbit.get_completed_torrents(cfg["qbit_category"]) except Exception: log.exception("Failed to fetch torrents") _sleep(poll_interval, lambda: running) diff --git a/summary.md b/summary.md index 1cab52b..e8b3401 100644 --- a/summary.md +++ b/summary.md @@ -4,7 +4,7 @@ ## Purpose -StashHandler sits between qBittorrent and Stash (both running in Docker). It polls qBit's API for completed torrents, hashes media files, deduplicates them via a SQLite database, and moves new files into Stash's import directory. +StashHandler sits between qBittorrent and Stash (both running in Docker). It polls qBit's API for completed torrents filtered by category, hashes media files, deduplicates them via a SQLite database, and copies new files into Stash's import directory (preserving originals for continued seeding). **Problems solved:** 1. Deleted files in Stash don't get reimported (hash DB remembers them) @@ -33,7 +33,8 @@ Designed for Docker Swarm — no host-local config files, everything via env var | `hasher.py` | Streaming file hashing (sha256 default, blake3 optional) in 64KB chunks | | `qbit_client.py` | qBittorrent API client — login, session cookies, auto re-auth on 403 | | `Dockerfile` | python:3.12-slim, entrypoint is `stash_handler.py`, default command `daemon` | -| `docker-compose.yaml` | Example compose with stash-handler, qbittorrent, stash services | +| `docker-compose.yaml` | Dev compose — builds from source, includes qbittorrent + stash, DEBUG logging | +| `docker-compose.prod.example.yaml` | Prod compose template — pulls from `git.fabledsword.com/bvandeusen/stashhandler:latest`, stash-handler only | | `requirements.txt` | `requests` (only dependency) | ## Modes of Operation @@ -44,7 +45,7 @@ docker run stash-handler # or: python stash_handler.py daemon ``` - Polls qBit API at `POLL_INTERVAL` seconds -- Fetches completed torrents via `/api/v2/torrents/info?filter=completed` +- Fetches completed torrents via `/api/v2/torrents/info?filter=completed&category=` - Skips already-processed torrents (by info_hash in `processed_torrents` table) - For each new torrent: walks media files, hashes, deduplicates, transfers new files to import dir - Clean shutdown on SIGINT/SIGTERM (1-second sleep granularity) @@ -71,7 +72,8 @@ docker exec stash-handler python stash_handler.py scan [--import] [path] | `POLL_INTERVAL` | `60` | Seconds between qBit API polls | | `MEDIA_EXTENSIONS` | `.mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v` | Comma-separated list of media file extensions | | `HASH_ALGORITHM` | `sha256` | Hash algorithm (`sha256` or `blake3`) | -| `MOVE_MODE` | `move` | File transfer mode: `move`, `hardlink`, or `copy` | +| `MOVE_MODE` | `copy` | File transfer mode: `copy`, `hardlink`, or `move` | +| `QBIT_CATEGORY` | `Stash` | qBit category to filter torrents by (shared qBit instance support) | | `LOG_LEVEL` | `INFO` | Python logging level | ## Database Schema