qBittorrent returns completion_on = -1 for torrents that were never completed. datetime.fromtimestamp(-1) throws OSError on Windows. Added explicit check for negative values and try/except for safety. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
310 lines
9.3 KiB
Python
310 lines
9.3 KiB
Python
#!/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
|
||
|
||
# ============================================================
|
||
# CONFIG
|
||
# ============================================================
|
||
|
||
CLIENTS = [
|
||
{
|
||
"name": "UltraCC Seedbox",
|
||
"max_concurrent": 20,
|
||
"qbt": {
|
||
"host": "https://vladob.zen.usbx.me/qbittorrent",
|
||
"username": "vladob",
|
||
"password": "jCni3U6d#y4bfcm",
|
||
"VERIFY_WEBUI_CERTIFICATE": False,
|
||
},
|
||
},
|
||
{
|
||
"name": "Local qBittorrent",
|
||
"max_concurrent": 20,
|
||
"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 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 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()
|