#!/usr/bin/env python3 # -*- coding: utf-8 -*- import time from datetime import datetime, timedelta import pymysql import qbittorrentapi import bencodepy # ============================== # ⚙ CONFIGURATION # ============================== DB_CONFIG = { "host": "192.168.1.76", "port": 3307, "user": "root", "password": "Vlado9674+", "database": "torrents", "charset": "utf8mb4", "autocommit": True, } QBT_CONFIG = { "host": "192.168.1.76", "port": 8080, "username": "admin", "password": "adminadmin", } MAX_ACTIVE_DOWNLOADS = 10 LOOP_SLEEP_SECONDS = 60 # Torrent označíme jako "dead" pokud nebyl nikdy "seen_complete" # více než X minut od přidání DEAD_TORRENT_MINUTES = 5 DEFAULT_SAVE_PATH = None # ============================== # 🔧 CONNECT # ============================== db = pymysql.connect(**DB_CONFIG) cursor = db.cursor(pymysql.cursors.DictCursor) qb = qbittorrentapi.Client( host=QBT_CONFIG["host"], port=QBT_CONFIG["port"], username=QBT_CONFIG["username"], password=QBT_CONFIG["password"], ) try: qb.auth_log_in() print("✅ Connected to qBittorrent.") except Exception as e: print("❌ Could not connect:", e) raise SystemExit(1) # ============================== # 🧪 TORRENT VALIDATION # ============================== def is_valid_torrent(blob: bytes) -> bool: """ Returns True only if BLOB is a valid .torrent file. """ try: data = bencodepy.decode(blob) return isinstance(data, dict) and b"info" in data except Exception: return False # ============================== # 🔄 SYNC FROM QB → DB # ============================== def sync_qb_to_db(): torrents = qb.torrents_info() for t in torrents: completion_dt = None if getattr(t, "completion_on", 0): try: completion_dt = datetime.fromtimestamp(t.completion_on) except: pass sql = """ UPDATE torrents SET qb_added = 1, qb_hash = COALESCE(qb_hash, %s), qb_state = %s, qb_progress = %s, qb_savepath = %s, qb_completed_datetime = IF(%s IS NOT NULL AND qb_completed_datetime IS NULL, %s, qb_completed_datetime), qb_last_update = NOW() WHERE qb_hash = %s OR torrent_hash = %s """ cursor.execute(sql, ( t.hash, t.state, float(t.progress) * 100.0, getattr(t, "save_path", None), completion_dt, completion_dt, t.hash, t.hash )) # ============================== # 🧹 HANDLE COMPLETED + DEAD TORRENTS # ============================== def handle_completed_and_dead(): torrents = qb.torrents_info() for t in torrents: t_hash = t.hash state = t.state progress = float(t.progress) # ========================== # ✔ COMPLETED # ========================== if progress >= 1.0 or state in {"completed", "uploading", "stalledUP", "queuedUP"}: print(f"✅ Completed torrent → remove (keep data): {t.name}") try: qb.torrents_delete(torrent_hashes=t_hash, delete_files=False) except Exception as e: print("⚠️ delete failed:", e) cursor.execute(""" UPDATE torrents SET qb_state='completed', qb_progress=100, qb_completed_datetime=NOW(), qb_last_update=NOW() WHERE qb_hash=%s OR torrent_hash=%s """, (t_hash, t_hash)) continue # ========================== # ❌ DEAD TORRENT (never seen_complete) # ========================== props = qb.torrents_properties(t_hash) seen = getattr(props, "last_seen", 0) if seen == -1: # never seen complete added_dt = getattr(t, "added_on", 0) if added_dt: added_time = datetime.fromtimestamp(added_dt) if datetime.now() - added_time > timedelta(minutes=DEAD_TORRENT_MINUTES): print(f"💀 Dead torrent (> {DEAD_TORRENT_MINUTES} min unseen): {t.name}") try: qb.torrents_delete(torrent_hashes=t_hash, delete_files=True) except: pass cursor.execute(""" UPDATE torrents SET qb_state='dead', qb_last_update=NOW() WHERE qb_hash=%s OR torrent_hash=%s """, (t_hash, t_hash)) # ============================== # 📊 COUNT ACTIVE DOWNLOADS # ============================== def count_active_downloads(): torrents = qb.torrents_info(filter="all") return sum(1 for t in torrents if float(t.progress) < 1.0) # ============================== # ➕ ENQUEUE NEW TORRENTS # ============================== def enqueue_new_torrents(): active = count_active_downloads() print("DEBUG active =", active) if active >= MAX_ACTIVE_DOWNLOADS: print(f"📦 {active}/{MAX_ACTIVE_DOWNLOADS} active → no enqueue") return slots = MAX_ACTIVE_DOWNLOADS - active sql = """ SELECT id, torrent_hash, torrent_content, torrent_filename, added_datetime FROM torrents WHERE (qb_added IS NULL OR qb_added = 0) AND torrent_content IS NOT NULL ORDER BY added_datetime DESC -- <── take NEWEST FIRST LIMIT %s """ cursor.execute(sql, (slots,)) rows = cursor.fetchall() if not rows: print("ℹ️ No new torrents") return for row in rows: t_id = row["id"] t_hash = row["torrent_hash"] blob = row["torrent_content"] filename = row.get("torrent_filename", "unknown.torrent") if not blob: print("⚠️ empty blob, skip") continue # ========================== # 🧪 VALIDATION OF .TORRENT # ========================== if not is_valid_torrent(blob): print(f"❌ INVALID TORRENT id={t_id}, size={len(blob)} → deleting content") cursor.execute(""" UPDATE torrents SET qb_state='invalid', torrent_content=NULL, qb_last_update=NOW() WHERE id=%s """, (t_id,)) continue # ========================== # ➕ ADD TORRENT # ========================== print(f"➕ Adding torrent: {filename} ({t_hash})") try: qb.torrents_add(torrent_files=blob, savepath=DEFAULT_SAVE_PATH) except Exception as e: print(f"❌ Failed to add {t_hash}: {e}") continue cursor.execute(""" UPDATE torrents SET qb_added=1, qb_hash=COALESCE(qb_hash, %s), qb_state='added', qb_last_update=NOW() WHERE id=%s """, (t_hash, t_id)) # ============================== # 🏁 MAIN LOOP # ============================== print("🚀 Worker started") try: while True: print(f"\n⏱ Loop {datetime.now():%Y-%m-%d %H:%M:%S}") sync_qb_to_db() handle_completed_and_dead() enqueue_new_torrents() print(f"🛌 Sleep {LOOP_SLEEP_SECONDS}s\n") time.sleep(LOOP_SLEEP_SECONDS) except KeyboardInterrupt: print("🛑 Stopping worker...") finally: db.close() print("👋 Bye.")