Files
bvandeusen 5bcb35e737 Implement per-file import for in-progress torrents
Files within a downloading torrent are now imported as soon as their
individual progress reaches 100%, without waiting for the full torrent
to complete. A new processed_files table (info_hash, file_name) tracks
which files have been handled to avoid redundant re-hashing each poll.

- db.py: add processed_files table, is_file_processed, record_processed_file
- qbit_client.py: replace get_completed_torrents with get_torrents(filter, category)
- stash_handler.py: extract process_torrent_files helper, daemon now polls
  both downloading and completed torrents each cycle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:51:52 -05:00

278 lines
8.8 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 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()