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:
+1
-1
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user