Files
StashHandler/stash_handler.py
T
bvandeusen 6adfd77236 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>
2026-02-16 11:38:17 -05:00

224 lines
6.9 KiB
Python

#!/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", "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"),
}
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(cfg["qbit_category"])
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()