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>
This commit is contained in:
2026-02-18 21:51:52 -05:00
parent 6adfd77236
commit 5bcb35e737
4 changed files with 134 additions and 42 deletions
+29 -2
View File
@@ -23,6 +23,14 @@ def init_db(db_path):
processed_at TEXT NOT NULL processed_at TEXT NOT NULL
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS processed_files (
info_hash TEXT NOT NULL,
file_name TEXT NOT NULL,
processed_at TEXT NOT NULL,
PRIMARY KEY (info_hash, file_name)
)
""")
conn.commit() conn.commit()
return conn return conn
@@ -50,16 +58,35 @@ def increment_seen(conn, file_hash):
def is_torrent_processed(conn, info_hash): def is_torrent_processed(conn, info_hash):
"""Check if a torrent has already been processed.""" """Check if a torrent has already been fully processed."""
row = conn.execute("SELECT 1 FROM processed_torrents WHERE info_hash = ?", (info_hash,)).fetchone() row = conn.execute("SELECT 1 FROM processed_torrents WHERE info_hash = ?", (info_hash,)).fetchone()
return row is not None return row is not None
def record_torrent(conn, info_hash, torrent_name): def record_torrent(conn, info_hash, torrent_name):
"""Record a torrent as processed.""" """Record a torrent as fully processed."""
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
conn.execute( conn.execute(
"INSERT OR IGNORE INTO processed_torrents (info_hash, torrent_name, processed_at) VALUES (?, ?, ?)", "INSERT OR IGNORE INTO processed_torrents (info_hash, torrent_name, processed_at) VALUES (?, ?, ?)",
(info_hash, torrent_name, now), (info_hash, torrent_name, now),
) )
conn.commit() conn.commit()
def is_file_processed(conn, info_hash, file_name):
"""Check if an individual file within a torrent has already been processed."""
row = conn.execute(
"SELECT 1 FROM processed_files WHERE info_hash = ? AND file_name = ?",
(info_hash, file_name),
).fetchone()
return row is not None
def record_processed_file(conn, info_hash, file_name):
"""Record an individual file within a torrent as processed."""
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"INSERT OR IGNORE INTO processed_files (info_hash, file_name, processed_at) VALUES (?, ?, ?)",
(info_hash, file_name, now),
)
conn.commit()
+5 -3
View File
@@ -37,9 +37,11 @@ class QBitClient:
resp.raise_for_status() resp.raise_for_status()
return resp return resp
def get_completed_torrents(self, category=None): def get_torrents(self, filter=None, category=None):
"""Return a list of completed torrent dicts from qBittorrent.""" """Return a list of torrent dicts, optionally filtered by state and/or category."""
params = {"filter": "completed"} params = {}
if filter:
params["filter"] = filter
if category: if category:
params["category"] = category params["category"] = category
resp = self._request("GET", "/api/v2/torrents/info", params=params) resp = self._request("GET", "/api/v2/torrents/info", params=params)
+86 -32
View File
@@ -75,6 +75,64 @@ def transfer_file(src, dst, mode):
# --- Daemon mode --- # --- 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): def daemon(cfg):
qbit = QBitClient(cfg["qbit_url"], cfg["qbit_username"], cfg["qbit_password"]) qbit = QBitClient(cfg["qbit_url"], cfg["qbit_username"], cfg["qbit_password"])
conn = db.init_db(cfg["db_path"]) conn = db.init_db(cfg["db_path"])
@@ -84,6 +142,7 @@ def daemon(cfg):
algorithm = cfg["hash_algorithm"] algorithm = cfg["hash_algorithm"]
move_mode = cfg["move_mode"] move_mode = cfg["move_mode"]
poll_interval = cfg["poll_interval"] poll_interval = cfg["poll_interval"]
category = cfg["qbit_category"]
running = True running = True
@@ -95,49 +154,44 @@ def daemon(cfg):
signal.signal(signal.SIGINT, stop) signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGTERM, stop)
log.info("Daemon started — polling every %ds", poll_interval) log.info("Daemon started — polling every %ds (category: %s)", poll_interval, category)
while running: while running:
try: try:
torrents = qbit.get_completed_torrents(cfg["qbit_category"]) downloading = qbit.get_torrents(filter="downloading", category=category)
completed = qbit.get_torrents(filter="completed", category=category)
except Exception: except Exception:
log.exception("Failed to fetch torrents") log.exception("Failed to fetch torrents from qBittorrent")
_sleep(poll_interval, lambda: running) _sleep(poll_interval, lambda: running)
continue continue
for t in torrents: # Process in-progress torrents — import any individually complete files
for t in downloading:
if not running: if not running:
break break
info_hash = t["hash"] if db.is_torrent_processed(conn, t["hash"]):
name = t["name"]
if db.is_torrent_processed(conn, info_hash):
continue 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,
)
log.info("Processing torrent: %s (%s)", name, info_hash) # Process completed torrents — import remaining files and mark torrent done
torrent_path = staging / t.get("content_path", name).lstrip("/") for t in completed:
if not torrent_path.exists(): if not running:
# Fall back: content_path from qBit may be absolute or relative break
torrent_path = staging / name if db.is_torrent_processed(conn, t["hash"]):
continue
new_count = 0 log.info("Completed torrent: %s (%s)", t["name"], t["hash"])
dup_count = 0 new, dup, _ = process_torrent_files(
for fpath in walk_media(torrent_path if torrent_path.is_dir() else torrent_path.parent, extensions): conn, qbit, t, staging, import_dir, extensions, algorithm, move_mode
file_hash = hash_file(fpath, algorithm) )
if db.is_known(conn, file_hash): db.record_torrent(conn, t["hash"], t["name"])
db.increment_seen(conn, file_hash) log.info("Torrent done: '%s'%d imported, %d duplicates", t["name"], new, dup)
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) _sleep(poll_interval, lambda: running)
+14 -5
View File
@@ -4,7 +4,7 @@
## Purpose ## Purpose
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). StashHandler sits between qBittorrent and Stash (both running in Docker). It polls qBit's API for 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). Individual files within in-progress torrents are imported as soon as they complete — the daemon does not wait for the full torrent to finish.
**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)
@@ -29,7 +29,7 @@ Designed for Docker Swarm — no host-local config files, everything via env var
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `stash_handler.py` | Entry point — `daemon` and `scan` CLI commands, config loading from env vars | | `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` | | `db.py` | SQLite operations — `init_db`, `is_known`, `record_file`, `is_torrent_processed`, `record_torrent`, `increment_seen`, `is_file_processed`, `record_processed_file` |
| `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` |
@@ -45,9 +45,11 @@ 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&category=<QBIT_CATEGORY>` - Each poll fetches both `downloading` and `completed` torrents filtered by `QBIT_CATEGORY`
- Skips already-processed torrents (by info_hash in `processed_torrents` table) - Uses `/api/v2/torrents/files` to get per-file progress (0.01.0) for each torrent
- For each new torrent: walks media files, hashes, deduplicates, transfers new files to import dir - Files with `progress < 1.0` are skipped — imported as soon as they reach 1.0
- Per-file state tracked in `processed_files` table to avoid re-hashing on subsequent polls
- Completed torrents are marked in `processed_torrents` and never checked again
- Clean shutdown on SIGINT/SIGTERM (1-second sleep granularity) - Clean shutdown on SIGINT/SIGTERM (1-second sleep granularity)
### Scan mode ### Scan mode
@@ -95,6 +97,13 @@ CREATE TABLE processed_torrents (
torrent_name TEXT, torrent_name TEXT,
processed_at TEXT NOT NULL processed_at TEXT NOT NULL
); );
CREATE TABLE processed_files (
info_hash TEXT NOT NULL,
file_name TEXT NOT NULL, -- relative to qBit save_path (= staging root)
processed_at TEXT NOT NULL,
PRIMARY KEY (info_hash, file_name)
);
``` ```
## Volume Layout (Docker) ## Volume Layout (Docker)