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
+86 -32
View File
@@ -75,6 +75,64 @@ def transfer_file(src, dst, 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):
qbit = QBitClient(cfg["qbit_url"], cfg["qbit_username"], cfg["qbit_password"])
conn = db.init_db(cfg["db_path"])
@@ -84,6 +142,7 @@ def daemon(cfg):
algorithm = cfg["hash_algorithm"]
move_mode = cfg["move_mode"]
poll_interval = cfg["poll_interval"]
category = cfg["qbit_category"]
running = True
@@ -95,49 +154,44 @@ def daemon(cfg):
signal.signal(signal.SIGINT, 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:
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:
log.exception("Failed to fetch torrents")
log.exception("Failed to fetch torrents from qBittorrent")
_sleep(poll_interval, lambda: running)
continue
for t in torrents:
# Process in-progress torrents — import any individually complete files
for t in downloading:
if not running:
break
info_hash = t["hash"]
name = t["name"]
if db.is_torrent_processed(conn, info_hash):
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,
)
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)
# 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)