#!/usr/bin/env python3 # -*- coding: utf-8 -*- from datetime import datetime, timedelta import pymysql import qbittorrentapi import bencodepy from EmailMessagingGraph import send_mail # ============================== # ⚙ 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", } # ZVÝŠENO NA 100 dle požadavku MAX_ACTIVE_DOWNLOADS = 100 # JAK DLOUHO ČEKAT? # Doporučuji alespoň 3 dny (4320 minut). # Pokud se do 3 dnů neobjeví nikdo, kdo má 100% souboru, je to pravděpodobně mrtvé. DEAD_TORRENT_DAYS = 3 DEAD_TORRENT_MINUTES = DEAD_TORRENT_DAYS * 24 * 60 DEFAULT_SAVE_PATH = None MAIL_TO = "vladimir.buzalka@buzalka.cz" MAX_LIST_ITEMS = 50 # cap lists in email # ============================== # 🧮 RUNTIME STATS + LISTS # ============================== RUN_START = datetime.now() stat_synced = 0 stat_completed = 0 stat_dead = 0 stat_enqueued = 0 deleted_completed = [] # list[str] deleted_dead = [] # list[str] added_new = [] # list[str] active_downloading = [] # list[str] # ============================== # 🔧 CONNECT # ============================== db = pymysql.connect(**DB_CONFIG) cursor = db.cursor(pymysql.cursors.DictCursor) qb = qbittorrentapi.Client(**QBT_CONFIG) try: qb.auth_log_in() print("✅ Connected to qBittorrent.") except Exception as e: raise SystemExit(f"❌ Could not connect to qBittorrent: {e}") # ============================== # 🧪 TORRENT VALIDATION # ============================== def is_valid_torrent(blob: bytes) -> bool: 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(): global stat_synced torrents = qb.torrents_info() stat_synced = len(torrents) for t in torrents: completion_dt = None if getattr(t, "completion_on", 0): try: completion_dt = datetime.fromtimestamp(t.completion_on) except Exception: pass cursor.execute(""" 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 """, ( 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 # ============================== def handle_completed_and_dead(): global stat_completed, stat_dead # Načteme info o torrentech torrents = qb.torrents_info() for t in torrents: t_hash = t.hash state = t.state progress = float(t.progress) # Získání dostupnosti (availability) - defaultně -1 pokud není k dispozici availability = float(getattr(t, "availability", -1)) # Získání času přidání added_ts = getattr(t, "added_on", 0) added_dt = datetime.fromtimestamp(added_ts) if added_ts > 0 else datetime.now() age_in_minutes = (datetime.now() - added_dt).total_seconds() / 60 # --------------------------- # 1. ✔ COMPLETED (Hotovo) # --------------------------- if progress >= 1.0 or state in {"completed", "uploading", "stalledUP", "queuedUP"}: stat_completed += 1 deleted_completed.append(t.name) try: # Smažeme z QB, ale necháme data na disku qb.torrents_delete(torrent_hashes=t_hash, delete_files=False) except Exception as e: print(f"⚠️ delete (keep data) failed for {t.name}: {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 # --------------------------- # 2. ❌ DEAD (Mrtvý) # --------------------------- # Logika: Je to starší než limit? A ZÁROVEŇ je dostupnost < 1 (nikdo nemá celý soubor)? is_old_enough = age_in_minutes > DEAD_TORRENT_MINUTES is_unavailable = availability < 1.0 if is_old_enough and is_unavailable: stat_dead += 1 deleted_dead.append(f"{t.name} (Avail: {availability:.2f})") try: # Smažeme z QB včetně nedotažených souborů qb.torrents_delete(torrent_hashes=t_hash, delete_files=True) except Exception as e: print(f"⚠️ delete (files) failed for {t.name}: {e}") cursor.execute(""" UPDATE torrents SET qb_state='dead', qb_last_update=NOW() WHERE qb_hash=%s OR torrent_hash=%s """, (t_hash, t_hash)) # ============================== # 📊 ACTIVE DOWNLOADS # ============================== def count_active_downloads(): # Počítáme jen ty, co nejsou hotové (progress < 100%) return sum(1 for t in qb.torrents_info() if float(t.progress) < 1.0) def snapshot_active_downloading(): """ Capture current actively downloading torrents (progress < 100%). """ active = [] for t in qb.torrents_info(): prog = float(t.progress) avail = float(getattr(t, "availability", 0)) if prog < 1.0: active.append(f"{t.name} — {prog * 100:.1f}% — Avail:{avail:.2f}") return sorted(active) # ============================== # ➕ ENQUEUE NEW TORRENTS # ============================== def enqueue_new_torrents(): global stat_enqueued active = count_active_downloads() # Pokud máme plno (100+), nic nepřidáváme if active >= MAX_ACTIVE_DOWNLOADS: return # Kolik slotů zbývá do 100 slots = MAX_ACTIVE_DOWNLOADS - active cursor.execute(""" SELECT id, torrent_hash, torrent_content, torrent_filename FROM torrents WHERE (qb_added IS NULL OR qb_added = 0) AND torrent_content IS NOT NULL AND (qb_state IS NULL OR qb_state != 'dead') ORDER BY added_datetime DESC LIMIT %s """, (slots,)) rows = cursor.fetchall() for row in rows: blob = row["torrent_content"] if not blob: continue if not is_valid_torrent(blob): cursor.execute(""" UPDATE torrents SET qb_state='invalid', torrent_content=NULL, qb_last_update=NOW() WHERE id=%s """, (row["id"],)) continue # ➕ Add torrent try: qb.torrents_add(torrent_files=blob, savepath=DEFAULT_SAVE_PATH) except Exception as e: print(f"❌ Failed to add {row['torrent_hash']}: {e}") continue stat_enqueued += 1 added_new.append(row.get("torrent_filename") or row["torrent_hash"]) cursor.execute(""" UPDATE torrents SET qb_added=1, qb_hash=COALESCE(qb_hash, %s), qb_state='added', qb_last_update=NOW() WHERE id=%s """, (row["torrent_hash"], row["id"])) # ============================== # ✉️ EMAIL HELPERS # ============================== def format_list(title: str, items: list[str]) -> list[str]: lines = [] if not items: return [f"{title}: (none)"] lines.append(f"{title}: {len(items)}") shown = items[:MAX_LIST_ITEMS] for it in shown: lines.append(f" - {it}") if len(items) > MAX_LIST_ITEMS: lines.append(f" ... (+{len(items) - MAX_LIST_ITEMS} more)") return lines # ============================== # 🏁 MAIN (ONE RUN) # ============================== print("🚀 QB worker run started") try: sync_qb_to_db() handle_completed_and_dead() enqueue_new_torrents() # Snapshot after enqueue/deletions, so email reflects end-state active_downloading = snapshot_active_downloading() finally: db.close() # ============================== # 📧 EMAIL REPORT # ============================== RUN_END = datetime.now() body_lines = [ f"Run started : {RUN_START:%Y-%m-%d %H:%M:%S}", f"Run finished: {RUN_END:%Y-%m-%d %H:%M:%S}", "", f"QB torrents synced : {stat_synced}", f"Completed removed : {stat_completed}", f"Dead removed : {stat_dead}", f"New torrents added : {stat_enqueued}", f"Active downloads : {len(active_downloading)} (Max: {MAX_ACTIVE_DOWNLOADS})", "", ] body_lines += format_list("Deleted (completed, kept data)", deleted_completed) body_lines.append("") body_lines += format_list("Deleted (DEAD > 3 days & Avail < 1.0)", deleted_dead) body_lines.append("") body_lines += format_list("Newly added to qBittorrent", added_new) body_lines.append("") body_lines += format_list("Actively downloading now", active_downloading) send_mail( to=MAIL_TO, subject=f"qBittorrent worker – {RUN_START:%Y-%m-%d %H:%M}", body="\n".join(body_lines), html=False, ) print("📧 Email report sent") print("🎉 DONE")