Files
torrents/Seedbox/70 Manager.py
Vladimir Buzalka b37db5397e Fix handle_completed — guard against invalid completion_on timestamp
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>
2026-03-02 07:02:21 +01:00

310 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()