#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Download Manager — Multi-client (UltraCC + lokální qBittorrent) Smyčka každých N minut pro každý klient: 1. Dokončené torrenty → odeber z qBittorrentu (data zachovej), zapiš do DB 2. Spočítej volné sloty 3. Doplň nové torrenty dle priority: seeders + velikost Oba klienti sdílí stejnou DB frontu. Torrent "nárokovaný" jedním klientem (qb_state='added') nebude nabídnut druhému klientovi. """ import pymysql import qbittorrentapi import sys from datetime import datetime, timedelta # ============================================================ # CONFIG # ============================================================ DEAD_AFTER_HOURS = 72 # progress < 95% po 72h → dead DEAD_PROGRESS_THRESHOLD = 95.0 STUCK_AFTER_HOURS = 168 # progress >= 95% ale < 100% po 7 dnech → dead CLIENTS = [ { "name": "UltraCC Seedbox", "max_concurrent": 30, "qbt": { "host": "https://vladob.zen.usbx.me/qbittorrent", "username": "vladob", "password": "jCni3U6d#y4bfcm", "VERIFY_WEBUI_CERTIFICATE": False, }, }, { "name": "Local qBittorrent", "max_concurrent": 30, "qbt": { "host": "192.168.1.76", "port": 8080, "username": "admin", "password": "adminadmin", }, }, ] DB_CONFIG = { "host": "192.168.1.76", "port": 3306, "user": "root", "password": "Vlado9674+", "database": "torrents", "charset": "utf8mb4", "autocommit": True, } # ============================================================ # PRIORITY SQL # Pořadí: nejlepší seeders + nejmenší soubory první # # DŮLEŽITÉ: 'added' je v exclusion listu — torrent nárokovaný # jedním klientem nebude nabídnut druhému. # ============================================================ SELECT_NEXT = """ SELECT id, torrent_hash, torrent_content, title_visible, size_pretty, seeders FROM torrents WHERE torrent_content IS NOT NULL AND qb_completed_datetime IS NULL AND (qb_state IS NULL OR qb_state NOT IN ( 'added', 'incomplete', 'invalid', 'dead', 'completed', 'completed_removed' )) ORDER BY CASE WHEN seeders >= 3 THEN 1 WHEN seeders >= 1 THEN 2 ELSE 3 END ASC, CASE WHEN size_pretty LIKE '%%MB%%' THEN 0 ELSE 1 END ASC, seeders DESC, added_datetime DESC LIMIT %s """ # ============================================================ # CONNECT # ============================================================ def connect_db(): return pymysql.connect(**DB_CONFIG) def connect_qbt(cfg: dict): qbt = qbittorrentapi.Client(**cfg) qbt.auth_log_in() return qbt # ============================================================ # STEP 1: Handle completed torrents # ============================================================ def handle_completed(qbt, cursor): """ Dokončené torrenty odebere z qBittorrentu (data zůstanou) a zapíše qb_completed_datetime do DB. """ removed = 0 for t in qbt.torrents_info(): if not t.completion_on or t.completion_on < 0: continue try: completed_dt = datetime.fromtimestamp(t.completion_on) except (OSError, ValueError, OverflowError): continue thash = t.hash.lower() try: qbt.torrents_delete(torrent_hashes=thash, delete_files=False) except Exception as e: print(f" ⚠️ Nelze odebrat {t.name[:40]}: {e}") continue cursor.execute(""" UPDATE torrents SET qb_state = 'completed_removed', qb_progress = 100, qb_completed_datetime = COALESCE(qb_completed_datetime, %s), qb_last_update = NOW() WHERE torrent_hash = %s OR qb_hash = %s """, (completed_dt, thash, thash)) print(f" ✅ Dokončen a odebrán: {t.name[:50]}") removed += 1 return removed # ============================================================ # STEP 1b: Handle dead torrents (z 50 MrtveTorrenty.py) # ============================================================ def handle_dead_torrents(qbt, cursor): """ Mrtvé torrenty: nízký progress po 72h NEBO zaseknutý >= 95% po 7 dnech. Smaže z qBittorrentu včetně souborů a označí v DB jako incomplete. """ now = datetime.now() deadline_a = now - timedelta(hours=DEAD_AFTER_HOURS) deadline_b = now - timedelta(hours=STUCK_AFTER_HOURS) dead_count = 0 for t in qbt.torrents_info(): # Přeskočit dokončené if t.completion_on and t.completion_on > 0: continue added_on = t.added_on if not added_on: continue added_dt = datetime.fromtimestamp(added_on) progress_pct = float(t.progress) * 100.0 # Kritérium A: nízký progress po 72h is_dead_a = (added_dt <= deadline_a) and (progress_pct < DEAD_PROGRESS_THRESHOLD) # Kritérium B: zaseknutý blízko 100% po 7 dnech is_dead_b = (added_dt <= deadline_b) and (progress_pct >= DEAD_PROGRESS_THRESHOLD) and (progress_pct < 100.0) if not is_dead_a and not is_dead_b: continue thash = t.hash.lower() reason = "nízký progress po 72h" if is_dead_a else "zaseknutý blízko 100% po 7 dnech" print(f" 💀 MRTVÝ ({reason}): {t.name[:50]}") print(f" Progress: {progress_pct:.1f}% | Stav: {t.state} | Seeds: {t.num_seeds}") try: qbt.torrents_delete(torrent_hashes=thash, delete_files=True) except Exception as e: print(f" ❌ Smazání selhalo: {e}") continue cursor.execute(""" UPDATE torrents SET qb_state = 'incomplete', qb_progress = %s, qb_last_update = NOW() WHERE torrent_hash = %s OR qb_hash = %s """, (progress_pct, thash, thash)) dead_count += 1 return dead_count # ============================================================ # STEP 2: Count active (non-completed) torrents in qBittorrent # ============================================================ def get_active_hashes(qbt): """ Vrátí sadu hashů torrentů, které jsou aktuálně v qBittorrentu. """ return {t.hash.lower() for t in qbt.torrents_info()} # ============================================================ # STEP 3: Add new torrents # ============================================================ def add_torrents(qbt, cursor, active_hashes, slots, client_name: str): """ Vybere z DB torrenty dle priority a přidá je do qBittorrentu. Přeskočí ty, které jsou tam již nahrané (dle active_hashes). Zapíše qb_client = client_name, aby bylo vidět, kdo torrent stahuje. """ cursor.execute(SELECT_NEXT, (slots * 3,)) rows = cursor.fetchall() added = 0 for row in rows: if added >= slots: break t_id, t_hash, content, title, size, seeders = row t_hash = t_hash.lower() if t_hash else "" if t_hash in active_hashes: # Torrent je v qB, ale DB ho ještě nemá označený cursor.execute(""" UPDATE torrents SET qb_added=1, qb_last_update=NOW() WHERE id=%s AND (qb_added IS NULL OR qb_added=0) """, (t_id,)) continue if not content: continue try: qbt.torrents_add( torrent_files={f"{t_hash}.torrent": content}, is_paused=False, ) except Exception as e: print(f" ❌ Nelze přidat {(title or '')[:40]}: {e}") continue cursor.execute(""" UPDATE torrents SET qb_added=1, qb_state='added', qb_client=%s, qb_last_update=NOW() WHERE id=%s """, (client_name, t_id)) print(f" ➕ Přidán: {(title or '')[:45]} " f"| {size or '?':>10} | seeds={seeders}") added += 1 return added # ============================================================ # PROCESS ONE CLIENT (steps 1-3) # ============================================================ def process_client(client_cfg: dict, cursor): name = client_cfg["name"] max_concurrent = client_cfg["max_concurrent"] print(f"\n ┌── {name} (max {max_concurrent}) ──") try: qbt = connect_qbt(client_cfg["qbt"]) except Exception as e: print(f" │ ❌ Nelze se připojit: {e}") print(f" └──") return # Krok 1: Dokončené print(f" │ [1] Kontrola dokončených...") removed = handle_completed(qbt, cursor) if removed == 0: print(f" │ Žádné dokončené.") # Krok 1b: Mrtvé torrenty print(f" │ [1b] Kontrola mrtvých torrentů...") dead = handle_dead_torrents(qbt, cursor) if dead == 0: print(f" │ Žádné mrtvé.") else: print(f" │ Odstraněno mrtvých: {dead}") # Krok 2: Stav slotů active_hashes = get_active_hashes(qbt) active = len(active_hashes) slots = max(0, max_concurrent - active) print(f" │ [2] Sloty: {active}/{max_concurrent} aktivních | volných: {slots}") # Krok 3: Doplnění if slots > 0: print(f" │ [3] Doplňuji {slots} torrentů...") added = add_torrents(qbt, cursor, active_hashes, slots, name) if added == 0: print(f" │ Žádné vhodné torrenty k přidání.") else: print(f" │ Přidáno: {added}") else: print(f" │ [3] Sloty plné ({active}/{max_concurrent}), přeskakuji.") print(f" └──") # ============================================================ # MAIN LOOP # ============================================================ def main(): sys.stdout.reconfigure(encoding="utf-8") now = datetime.now() print("=" * 60) print(f"DOWNLOAD MANAGER — Multi-client {now:%Y-%m-%d %H:%M:%S}") for c in CLIENTS: print(f" • {c['name']} (max {c['max_concurrent']})") print(f"Celkem slotů: {sum(c['max_concurrent'] for c in CLIENTS)}") print("=" * 60) db = connect_db() cursor = db.cursor() try: # DB statistiky — celkové cursor.execute(""" SELECT SUM(CASE WHEN qb_completed_datetime IS NOT NULL THEN 1 ELSE 0 END), SUM(CASE WHEN qb_state IS NULL AND torrent_content IS NOT NULL THEN 1 ELSE 0 END), SUM(CASE WHEN qb_state IN ('incomplete','dead') THEN 1 ELSE 0 END) FROM torrents """) db_completed, db_waiting, db_dead = cursor.fetchone() print(f" DB — staženo: {db_completed or 0} " f"| čeká: {db_waiting or 0} " f"| dead/incomplete: {db_dead or 0}") # DB statistiky — per-client cursor.execute(""" SELECT qb_client, COUNT(*) AS cnt FROM torrents WHERE qb_client IS NOT NULL GROUP BY qb_client """) per_client = cursor.fetchall() if per_client: parts = " | ".join(f"{name}: {cnt}" for name, cnt in per_client) print(f" DB — per-client: {parts}") # Zpracuj každý klient # (druhý klient vidí stav DB aktualizovaný prvním) for client_cfg in CLIENTS: process_client(client_cfg, cursor) finally: db.close() print("\n👋 Hotovo.") if __name__ == "__main__": main()