From f09d94e34ba728f2664e3ad15642b64b7525e158 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 16 Feb 2026 11:25:05 -0500 Subject: [PATCH] Initial commit: StashHandler Bridge between qBittorrent and Stash that polls for completed torrents, deduplicates via SHA256 hashing and SQLite, and imports new media files. Supports daemon mode (continuous polling) and scan mode (bulk operations). Configuration via environment variables for Docker Swarm compatibility. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 28 ++++++ Dockerfile | 7 ++ README.md | 75 +++++++++++++++ db.py | 65 +++++++++++++ docker-compose.yaml | 47 ++++++++++ hasher.py | 19 ++++ qbit_client.py | 48 ++++++++++ requirements.txt | 1 + stash_handler.py | 222 ++++++++++++++++++++++++++++++++++++++++++++ summary.md | 105 +++++++++++++++++++++ 10 files changed, 617 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 db.py create mode 100644 docker-compose.yaml create mode 100644 hasher.py create mode 100644 qbit_client.py create mode 100644 requirements.txt create mode 100644 stash_handler.py create mode 100644 summary.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c2cdbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ + +# Database +*.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Docker overrides +docker-compose.override.yaml + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1070c7a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY *.py . +ENTRYPOINT ["python", "stash_handler.py"] +CMD ["daemon"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e04afb2 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# 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. + +## Why + +- **No reimports** — If you delete a file in Stash, StashHandler remembers its hash and won't import it again. +- **Deduplication** — Duplicate files are caught before they reach Stash. +- **Bulk bootstrap** — Record hashes of your existing Stash library so future downloads are checked against it. + +## Quick Start + +1. Clone this repo +2. Configure environment variables in `docker-compose.yaml` (or via your swarm/stack config) +3. Deploy: + +```bash +# Docker Compose +docker compose up -d --build + +# Docker Swarm +docker stack deploy -c docker-compose.yaml stash-handler +``` + +## Modes + +### Daemon (default) + +Runs continuously, polling qBittorrent for completed torrents. + +```bash +docker run stash-handler # default command is "daemon" +``` + +### Scan + +Bulk-process files on disk. Useful for bootstrapping the dedup database or one-off imports. + +```bash +# Record hashes of existing files (no files moved) +docker exec stash-handler python stash_handler.py scan /staging + +# Record and import new files to Stash +docker exec stash-handler python stash_handler.py scan --import /staging + +# Record hashes from your existing Stash library (mount read-only) +docker exec stash-handler python stash_handler.py scan /stash-library +``` + +## Configuration + +All configuration is via environment variables. No config files needed — works with Docker Swarm secrets and stack deploys. + +| Variable | Default | Description | +|---|---|---| +| `QBIT_URL` | `http://qbittorrent:8080` | qBittorrent Web UI URL | +| `QBIT_USERNAME` | `admin` | qBit username | +| `QBIT_PASSWORD` | `adminadmin` | qBit password | +| `STAGING_DIR` | `/staging` | qBit download directory mount | +| `IMPORT_DIR` | `/import` | Stash import directory mount | +| `DB_PATH` | `/data/seen.db` | SQLite database path | +| `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` | +| `LOG_LEVEL` | `INFO` | Python log level | + +## Volumes + +```yaml +volumes: + - /data/staging:/staging:ro # qBit downloads (read-only) + - /data/stash-import:/import # Stash import dir (read-write) + - /data/stash-handler:/data # SQLite database (persistent) +``` diff --git a/db.py b/db.py new file mode 100644 index 0000000..e1154a2 --- /dev/null +++ b/db.py @@ -0,0 +1,65 @@ +import sqlite3 +from datetime import datetime, timezone + + +def init_db(db_path): + """Initialize the database and return a connection.""" + conn = sqlite3.connect(db_path) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute(""" + CREATE TABLE IF NOT EXISTS seen_files ( + hash TEXT PRIMARY KEY, + filename TEXT NOT NULL, + source TEXT, + info_hash TEXT, + first_seen TEXT NOT NULL, + times_seen INTEGER DEFAULT 1 + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS processed_torrents ( + info_hash TEXT PRIMARY KEY, + torrent_name TEXT, + processed_at TEXT NOT NULL + ) + """) + conn.commit() + return conn + + +def is_known(conn, file_hash): + """Check if a file hash is already in the database.""" + row = conn.execute("SELECT 1 FROM seen_files WHERE hash = ?", (file_hash,)).fetchone() + return row is not None + + +def record_file(conn, file_hash, filename, source, info_hash=None): + """Record a file hash in the database.""" + now = datetime.now(timezone.utc).isoformat() + conn.execute( + "INSERT OR IGNORE INTO seen_files (hash, filename, source, info_hash, first_seen) VALUES (?, ?, ?, ?, ?)", + (file_hash, filename, source, info_hash, now), + ) + conn.commit() + + +def increment_seen(conn, file_hash): + """Increment the times_seen counter for a known hash.""" + conn.execute("UPDATE seen_files SET times_seen = times_seen + 1 WHERE hash = ?", (file_hash,)) + conn.commit() + + +def is_torrent_processed(conn, info_hash): + """Check if a torrent has already been processed.""" + row = conn.execute("SELECT 1 FROM processed_torrents WHERE info_hash = ?", (info_hash,)).fetchone() + return row is not None + + +def record_torrent(conn, info_hash, torrent_name): + """Record a torrent as processed.""" + now = datetime.now(timezone.utc).isoformat() + conn.execute( + "INSERT OR IGNORE INTO processed_torrents (info_hash, torrent_name, processed_at) VALUES (?, ?, ?)", + (info_hash, torrent_name, now), + ) + conn.commit() diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..bbf2676 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,47 @@ +services: + stash-handler: + build: . + container_name: stash-handler + restart: unless-stopped + environment: + QBIT_URL: "http://qbittorrent:8080" + QBIT_USERNAME: "admin" + QBIT_PASSWORD: "adminadmin" + 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: "move" + LOG_LEVEL: "INFO" + volumes: + - /data/staging:/staging:ro + - /data/stash-import:/import + - /data/stash-handler:/data + depends_on: + - qbittorrent + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + container_name: qbittorrent + environment: + - PUID=1000 + - PGID=1000 + volumes: + - /data/staging:/downloads + - /data/qbittorrent/config:/config + ports: + - "8080:8080" + restart: unless-stopped + + stash: + image: stashapp/stash:latest + container_name: stash + volumes: + - /data/stash-import:/data/import + - /data/stash/config:/root/.stash + - /data/stash/library:/data/library + ports: + - "9999:9999" + restart: unless-stopped diff --git a/hasher.py b/hasher.py new file mode 100644 index 0000000..767965d --- /dev/null +++ b/hasher.py @@ -0,0 +1,19 @@ +import hashlib + + +def hash_file(path, algorithm="sha256"): + """Hash a file by streaming in 64KB chunks. Returns hex digest.""" + if algorithm == "blake3": + try: + import blake3 + h = blake3.blake3() + except ImportError: + raise ImportError("blake3 requested but not installed; pip install blake3") + else: + h = hashlib.new(algorithm) + + with open(path, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + + return h.hexdigest() diff --git a/qbit_client.py b/qbit_client.py new file mode 100644 index 0000000..ea2a48c --- /dev/null +++ b/qbit_client.py @@ -0,0 +1,48 @@ +import logging +import requests + +log = logging.getLogger(__name__) + + +class QBitClient: + def __init__(self, url, username, password): + self.url = url.rstrip("/") + self.username = username + self.password = password + self.session = requests.Session() + self._logged_in = False + + def login(self): + """Authenticate with qBittorrent and store the session cookie.""" + resp = self.session.post( + f"{self.url}/api/v2/auth/login", + data={"username": self.username, "password": self.password}, + ) + resp.raise_for_status() + if resp.text.strip() == "Ok.": + self._logged_in = True + log.info("Logged in to qBittorrent at %s", self.url) + else: + raise RuntimeError(f"qBit login failed: {resp.text}") + + def _request(self, method, endpoint, **kwargs): + """Make an API request, re-authenticating on 403.""" + if not self._logged_in: + self.login() + resp = self.session.request(method, f"{self.url}{endpoint}", **kwargs) + if resp.status_code == 403: + log.info("Session expired, re-authenticating") + self.login() + resp = self.session.request(method, f"{self.url}{endpoint}", **kwargs) + resp.raise_for_status() + return resp + + def get_completed_torrents(self): + """Return a list of completed torrent dicts from qBittorrent.""" + resp = self._request("GET", "/api/v2/torrents/info", params={"filter": "completed"}) + return resp.json() + + def get_torrent_files(self, info_hash): + """Return the file list for a specific torrent.""" + resp = self._request("GET", "/api/v2/torrents/files", params={"hash": info_hash}) + return resp.json() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/stash_handler.py b/stash_handler.py new file mode 100644 index 0000000..cc5e07b --- /dev/null +++ b/stash_handler.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""StashHandler — bridge between qBittorrent and Stash.""" + +import argparse +import logging +import os +import signal +import shutil +import time +from pathlib import Path + +import db +from hasher import hash_file +from qbit_client import QBitClient + +log = logging.getLogger("stash_handler") + +DEFAULT_MEDIA_EXTENSIONS = ".mp4,.mkv,.avi,.wmv,.mov,.webm,.flv,.m4v" + + +def load_config(): + """Build config dict from environment variables.""" + return { + "qbit_url": os.environ.get("QBIT_URL", "http://qbittorrent:8080"), + "qbit_username": os.environ.get("QBIT_USERNAME", "admin"), + "qbit_password": os.environ.get("QBIT_PASSWORD", "adminadmin"), + "staging_dir": os.environ.get("STAGING_DIR", "/staging"), + "import_dir": os.environ.get("IMPORT_DIR", "/import"), + "poll_interval": int(os.environ.get("POLL_INTERVAL", "60")), + "media_extensions": set( + 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"), + "db_path": os.environ.get("DB_PATH", "/data/seen.db"), + "log_level": os.environ.get("LOG_LEVEL", "INFO"), + } + + +def setup_logging(level="INFO"): + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def is_media(path, extensions): + return path.suffix.lower() in extensions + + +def walk_media(root, extensions): + """Yield all media file paths under root.""" + root = Path(root) + for p in sorted(root.rglob("*")): + if p.is_file() and is_media(p, extensions): + yield p + + +def transfer_file(src, dst, mode): + """Move, hardlink, or copy src to dst.""" + dst.parent.mkdir(parents=True, exist_ok=True) + if mode == "hardlink": + try: + os.link(src, dst) + except OSError: + log.warning("Hardlink failed, falling back to copy: %s", src) + shutil.copy2(src, dst) + elif mode == "copy": + shutil.copy2(src, dst) + else: # move + shutil.move(str(src), str(dst)) + + +# --- Daemon mode --- + +def daemon(cfg): + qbit = QBitClient(cfg["qbit_url"], cfg["qbit_username"], cfg["qbit_password"]) + conn = db.init_db(cfg["db_path"]) + extensions = cfg["media_extensions"] + staging = Path(cfg["staging_dir"]) + import_dir = Path(cfg["import_dir"]) + algorithm = cfg["hash_algorithm"] + move_mode = cfg["move_mode"] + poll_interval = cfg["poll_interval"] + + running = True + + def stop(*_): + nonlocal running + log.info("Shutting down...") + running = False + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + + log.info("Daemon started — polling every %ds", poll_interval) + + while running: + try: + torrents = qbit.get_completed_torrents() + except Exception: + log.exception("Failed to fetch torrents") + _sleep(poll_interval, lambda: running) + continue + + for t in torrents: + if not running: + break + info_hash = t["hash"] + name = t["name"] + + if db.is_torrent_processed(conn, info_hash): + continue + + log.info("Processing torrent: %s (%s)", name, info_hash) + torrent_path = staging / t.get("content_path", name).lstrip("/") + if not torrent_path.exists(): + # Fall back: content_path from qBit may be absolute or relative + torrent_path = staging / name + + new_count = 0 + dup_count = 0 + for fpath in walk_media(torrent_path if torrent_path.is_dir() else torrent_path.parent, extensions): + file_hash = hash_file(fpath, algorithm) + if db.is_known(conn, file_hash): + db.increment_seen(conn, file_hash) + dup_count += 1 + log.debug("Duplicate skipped: %s", fpath.name) + else: + rel = fpath.relative_to(staging) + dst = import_dir / rel + transfer_file(fpath, dst, move_mode) + db.record_file(conn, file_hash, str(rel), "torrent", info_hash) + new_count += 1 + log.info("Imported: %s", rel) + + db.record_torrent(conn, info_hash, name) + log.info("Torrent done: %s — %d new, %d duplicates", name, new_count, dup_count) + + _sleep(poll_interval, lambda: running) + + conn.close() + log.info("Shutdown complete") + + +def _sleep(seconds, check_fn, step=1): + """Sleep in small steps so we can respond to shutdown quickly.""" + elapsed = 0 + while elapsed < seconds and check_fn(): + time.sleep(min(step, seconds - elapsed)) + elapsed += step + + +# --- Scan mode --- + +def scan(cfg, path, do_import): + conn = db.init_db(cfg["db_path"]) + extensions = cfg["media_extensions"] + import_dir = Path(cfg["import_dir"]) + algorithm = cfg["hash_algorithm"] + move_mode = cfg["move_mode"] + scan_root = Path(path) + + total = 0 + new = 0 + duplicates = 0 + + for fpath in walk_media(scan_root, extensions): + total += 1 + file_hash = hash_file(fpath, algorithm) + + if db.is_known(conn, file_hash): + db.increment_seen(conn, file_hash) + duplicates += 1 + log.debug("Already known: %s", fpath.name) + else: + if do_import: + rel = fpath.relative_to(scan_root) + dst = import_dir / rel + transfer_file(fpath, dst, move_mode) + log.info("Imported: %s", rel) + db.record_file(conn, file_hash, str(fpath), "scan" if do_import else "bulk") + new += 1 + + conn.close() + + print(f"\nScan complete: {path}") + print(f" Total files: {total}") + print(f" New: {new}") + print(f" Already known: {duplicates}") + if do_import: + print(f" Imported to: {import_dir}") + else: + print(" (record-only mode — no files moved)") + + +# --- CLI --- + +def main(): + parser = argparse.ArgumentParser(description="StashHandler") + sub = parser.add_subparsers(dest="command") + + sub.add_parser("daemon", help="Poll qBittorrent and import new files") + + scan_p = sub.add_parser("scan", help="Bulk-scan files and record/import them") + scan_p.add_argument("path", nargs="?", default="/staging", help="Directory to scan") + scan_p.add_argument("--import", dest="do_import", action="store_true", + help="Move new files to import dir (default: record-only)") + + args = parser.parse_args() + cfg = load_config() + setup_logging(cfg["log_level"]) + + if args.command == "scan": + scan(cfg, args.path, args.do_import) + else: + daemon(cfg) + + +if __name__ == "__main__": + main() diff --git a/summary.md b/summary.md new file mode 100644 index 0000000..1cab52b --- /dev/null +++ b/summary.md @@ -0,0 +1,105 @@ +# StashHandler — Project Summary + +> **NOTE:** Update this file after any significant changes to the project (new features, architecture changes, new env vars, schema changes, etc.) + +## 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. + +**Problems solved:** +1. Deleted files in Stash don't get reimported (hash DB remembers them) +2. Duplicate content is caught before reaching Stash +3. Existing library files can be recorded for dedup without re-processing + +## Architecture + +``` +qBit (Docker) StashHandler (Docker) Stash (Docker) +/downloads/ ──bind──> /staging (read-only) + polls qBit API + hashes files, checks SQLite + /import (read-write) ──bind──> /data/import + /data/seen.db (persistent) +``` + +Designed for Docker Swarm — no host-local config files, everything via env vars. + +## Files + +| File | Purpose | +|---|---| +| `stash_handler.py` | Entry point — `daemon` and `scan` CLI commands, config loading from env vars | +| `db.py` | SQLite operations — `init_db`, `is_known`, `record_file`, `is_torrent_processed`, `record_torrent`, `increment_seen` | +| `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 | +| `requirements.txt` | `requests` (only dependency) | + +## Modes of Operation + +### Daemon mode (default) +``` +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` +- 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) + +### Scan mode +``` +docker exec stash-handler python stash_handler.py scan [--import] [path] +``` +- `scan /staging` — record hashes only (bootstrap dedup DB, no files moved) +- `scan --import /staging` — hash and move new files to import dir +- `scan /stash-library` — record existing Stash library hashes (mount read-only) +- Prints summary: total scanned, new, already known + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `QBIT_URL` | `http://qbittorrent:8080` | qBittorrent Web UI URL | +| `QBIT_USERNAME` | `admin` | qBit login username | +| `QBIT_PASSWORD` | `adminadmin` | qBit login password | +| `STAGING_DIR` | `/staging` | Mount point for qBit's download directory | +| `IMPORT_DIR` | `/import` | Mount point for Stash's import directory | +| `DB_PATH` | `/data/seen.db` | Path to SQLite database | +| `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` | +| `LOG_LEVEL` | `INFO` | Python logging level | + +## Database Schema + +SQLite at `DB_PATH`, WAL mode enabled. + +```sql +CREATE TABLE seen_files ( + hash TEXT PRIMARY KEY, + filename TEXT NOT NULL, + source TEXT, -- "torrent", "scan", "bulk" + info_hash TEXT, + first_seen TEXT NOT NULL, + times_seen INTEGER DEFAULT 1 +); + +CREATE TABLE processed_torrents ( + info_hash TEXT PRIMARY KEY, + torrent_name TEXT, + processed_at TEXT NOT NULL +); +``` + +## Volume Layout (Docker) + +```yaml +volumes: + - /data/staging:/staging:ro # qBit's download dir (read-only) + - /data/stash-import:/import # Stash's import dir (read-write) + - /data/stash-handler:/data # persistent DB +```