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 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 11:38:17 -05:00
parent f09d94e34b
commit 6adfd77236
7 changed files with 59 additions and 17 deletions
+1 -1
View File
@@ -2,6 +2,6 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY *.py . COPY *.py ./
ENTRYPOINT ["python", "stash_handler.py"] ENTRYPOINT ["python", "stash_handler.py"]
CMD ["daemon"] CMD ["daemon"]
+19 -6
View File
@@ -1,6 +1,6 @@
# StashHandler # 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 ## Why
@@ -10,18 +10,30 @@ A Docker service that bridges qBittorrent and Stash. It polls qBittorrent for co
## Quick Start ## Quick Start
1. Clone this repo ### Development
2. Configure environment variables in `docker-compose.yaml` (or via your swarm/stack config)
3. Deploy:
```bash ```bash
# Docker Compose git clone <repo-url> && cd stashhandler
# Edit environment variables in docker-compose.yaml
docker compose up -d --build 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 Swarm
docker stack deploy -c docker-compose.yaml stash-handler 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 ## Modes
### Daemon (default) ### Daemon (default)
@@ -62,7 +74,8 @@ All configuration is via environment variables. No config files needed — works
| `POLL_INTERVAL` | `60` | Seconds between polls | | `POLL_INTERVAL` | `60` | Seconds between polls |
| `MEDIA_EXTENSIONS` | `.mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v` | Comma-separated extensions | | `MEDIA_EXTENSIONS` | `.mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v` | Comma-separated extensions |
| `HASH_ALGORITHM` | `sha256` | `sha256` or `blake3` | | `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 | | `LOG_LEVEL` | `INFO` | Python log level |
## Volumes ## Volumes
+22
View File
@@ -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
+3 -2
View File
@@ -13,8 +13,9 @@ services:
POLL_INTERVAL: "300" POLL_INTERVAL: "300"
MEDIA_EXTENSIONS: ".mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v" MEDIA_EXTENSIONS: ".mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v"
HASH_ALGORITHM: "sha256" HASH_ALGORITHM: "sha256"
MOVE_MODE: "move" MOVE_MODE: "copy"
LOG_LEVEL: "INFO" QBIT_CATEGORY: "Stash"
LOG_LEVEL: "DEBUG"
volumes: volumes:
- /data/staging:/staging:ro - /data/staging:/staging:ro
- /data/stash-import:/import - /data/stash-import:/import
+5 -2
View File
@@ -37,9 +37,12 @@ class QBitClient:
resp.raise_for_status() resp.raise_for_status()
return resp return resp
def get_completed_torrents(self): def get_completed_torrents(self, category=None):
"""Return a list of completed torrent dicts from qBittorrent.""" """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() return resp.json()
def get_torrent_files(self, info_hash): def get_torrent_files(self, info_hash):
+3 -2
View File
@@ -31,7 +31,8 @@ def load_config():
os.environ.get("MEDIA_EXTENSIONS", DEFAULT_MEDIA_EXTENSIONS).split(",") os.environ.get("MEDIA_EXTENSIONS", DEFAULT_MEDIA_EXTENSIONS).split(",")
), ),
"hash_algorithm": os.environ.get("HASH_ALGORITHM", "sha256"), "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"), "db_path": os.environ.get("DB_PATH", "/data/seen.db"),
"log_level": os.environ.get("LOG_LEVEL", "INFO"), "log_level": os.environ.get("LOG_LEVEL", "INFO"),
} }
@@ -98,7 +99,7 @@ def daemon(cfg):
while running: while running:
try: try:
torrents = qbit.get_completed_torrents() torrents = qbit.get_completed_torrents(cfg["qbit_category"])
except Exception: except Exception:
log.exception("Failed to fetch torrents") log.exception("Failed to fetch torrents")
_sleep(poll_interval, lambda: running) _sleep(poll_interval, lambda: running)
+6 -4
View File
@@ -4,7 +4,7 @@
## Purpose ## 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:** **Problems solved:**
1. Deleted files in Stash don't get reimported (hash DB remembers them) 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 | | `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 | | `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` | | `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) | | `requirements.txt` | `requests` (only dependency) |
## Modes of Operation ## Modes of Operation
@@ -44,7 +45,7 @@ docker run stash-handler
# or: python stash_handler.py daemon # or: python stash_handler.py daemon
``` ```
- Polls qBit API at `POLL_INTERVAL` seconds - 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=<QBIT_CATEGORY>`
- Skips already-processed torrents (by info_hash in `processed_torrents` table) - 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 - For each new torrent: walks media files, hashes, deduplicates, transfers new files to import dir
- Clean shutdown on SIGINT/SIGTERM (1-second sleep granularity) - 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 | | `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 | | `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`) | | `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 | | `LOG_LEVEL` | `INFO` | Python logging level |
## Database Schema ## Database Schema