#!/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 process_torrent_files(conn, qbit, t, staging, import_dir, extensions, algorithm, move_mode): """ Process all complete files for a torrent using the qBit files API. Files with progress < 1.0 are skipped (still downloading). Files already in processed_files are skipped (already handled in a prior poll). Returns (new_count, dup_count, incomplete_count). """ info_hash = t["hash"] name = t["name"] new_count = 0 dup_count = 0 incomplete_count = 0 try: files = qbit.get_torrent_files(info_hash) except Exception: log.exception("Failed to get file list for torrent: %s", name) return 0, 0, 0 for f in files: file_name = f["name"] # relative to qBit's save_path (= our staging root) if f["progress"] < 1.0: incomplete_count += 1 continue if db.is_file_processed(conn, info_hash, file_name): continue fpath = staging / file_name if not fpath.exists(): log.warning("File reported complete by qBit but not found on disk: %s", fpath) continue if not is_media(fpath, extensions): # Not a media file — mark processed so we don't check it again db.record_processed_file(conn, info_hash, file_name) continue 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 = Path(file_name) 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_processed_file(conn, info_hash, file_name) return new_count, dup_count, incomplete_count 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"] category = cfg["qbit_category"] 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 (category: %s)", poll_interval, category) while running: try: downloading = qbit.get_torrents(filter="downloading", category=category) completed = qbit.get_torrents(filter="completed", category=category) except Exception: log.exception("Failed to fetch torrents from qBittorrent") _sleep(poll_interval, lambda: running) continue # Process in-progress torrents — import any individually complete files for t in downloading: if not running: break if db.is_torrent_processed(conn, t["hash"]): continue new, dup, incomplete = process_torrent_files( conn, qbit, t, staging, import_dir, extensions, algorithm, move_mode ) if new or dup: log.info( "In-progress '%s': %d imported, %d duplicates, %d still downloading", t["name"], new, dup, incomplete, ) # Process completed torrents — import remaining files and mark torrent done for t in completed: if not running: break if db.is_torrent_processed(conn, t["hash"]): continue log.info("Completed torrent: %s (%s)", t["name"], t["hash"]) new, dup, _ = process_torrent_files( conn, qbit, t, staging, import_dir, extensions, algorithm, move_mode ) db.record_torrent(conn, t["hash"], t["name"]) log.info("Torrent done: '%s' — %d imported, %d duplicates", t["name"], new, dup) _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()