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:
+86
-32
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user