This commit is contained in:
2025-12-08 19:26:13 +01:00
parent bc35cbdfac
commit 6e8395890d
2 changed files with 227 additions and 158 deletions

View File

@@ -2,16 +2,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time import time
from datetime import datetime from datetime import datetime, timedelta
import pymysql import pymysql
import qbittorrentapi import qbittorrentapi
import bencodepy
# ============================== # ==============================
# ⚙ CONFIG # ⚙ CONFIGURATION
# ============================== # ==============================
# MySQL (Tower)
DB_CONFIG = { DB_CONFIG = {
"host": "192.168.1.76", "host": "192.168.1.76",
"port": 3307, "port": 3307,
@@ -22,277 +23,273 @@ DB_CONFIG = {
"autocommit": True, "autocommit": True,
} }
# qBittorrent WebUI
QBT_CONFIG = { QBT_CONFIG = {
"host": "192.168.1.76", "host": "192.168.1.76",
"port": 8080, "port": 8080,
# pokud máš whitelist a bypass auth, username/password netřeba
"username": "admin", "username": "admin",
"password": "adminadmin", "password": "adminadmin",
} }
# Max. počet *aktivních* downloadů v qBittorrentu
MAX_ACTIVE_DOWNLOADS = 10 MAX_ACTIVE_DOWNLOADS = 10
# Jak často běží hlavní smyčka (sekundy)
LOOP_SLEEP_SECONDS = 60 LOOP_SLEEP_SECONDS = 60
# Jak dlouho může být torrent "stalled", než ho smažeme (sekundy) # Torrent označíme jako "dead" pokud nebyl nikdy "seen_complete"
STALLED_MAX_SECONDS = 60 * 60 # 60 minut # více než X minut od přidání
DEAD_TORRENT_MINUTES = 5
DEFAULT_SAVE_PATH = None
# Volitelně kam ukládat data (jinak použije default z qB)
DEFAULT_SAVE_PATH = None # např. r"/mnt/torrents/movies"
# ============================== # ==============================
# 🔗 CONNECTIONS # 🔧 CONNECT
# ============================== # ==============================
db = pymysql.connect(**DB_CONFIG) db = pymysql.connect(**DB_CONFIG)
cursor = db.cursor(pymysql.cursors.DictCursor) cursor = db.cursor(pymysql.cursors.DictCursor)
if QBT_CONFIG["username"] and QBT_CONFIG["password"]: qb = qbittorrentapi.Client(
qb = qbittorrentapi.Client( host=QBT_CONFIG["host"],
host=QBT_CONFIG["host"], port=QBT_CONFIG["port"],
port=QBT_CONFIG["port"], username=QBT_CONFIG["username"],
username=QBT_CONFIG["username"], password=QBT_CONFIG["password"],
password=QBT_CONFIG["password"], )
)
else:
# bez auth (whitelist / bypass)
qb = qbittorrentapi.Client(
host=QBT_CONFIG["host"],
port=QBT_CONFIG["port"],
)
try: try:
qb.auth_log_in() qb.auth_log_in()
print("✅ Connected to qBittorrent.") print("✅ Connected to qBittorrent.")
except Exception as e: except Exception as e:
print("❌ Could not connect to qBittorrent:", e) print("❌ Could not connect:", e)
raise SystemExit(1) raise SystemExit(1)
# ============================== # ==============================
# 🧠 HELPER FUNCTIONS # 🧪 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(): def sync_qb_to_db():
"""Synchronize state of torrents from qBittorrent into MySQL."""
torrents = qb.torrents_info() torrents = qb.torrents_info()
for t in torrents: for t in torrents:
# t.hash, t.name, t.state, t.progress (01), t.save_path,
# t.time_active (s), t.completion_on (unix timestamp, 0 if none)
completion_dt = None completion_dt = None
if getattr(t, "completion_on", 0): if getattr(t, "completion_on", 0):
try: try:
completion_dt = datetime.fromtimestamp(t.completion_on) completion_dt = datetime.fromtimestamp(t.completion_on)
except Exception: except:
completion_dt = None pass
sql = """ sql = """
UPDATE torrents UPDATE torrents
SET SET qb_added = 1,
qb_added = 1,
qb_hash = COALESCE(qb_hash, %s), qb_hash = COALESCE(qb_hash, %s),
qb_state = %s, qb_state = %s,
qb_progress = %s, qb_progress = %s,
qb_savepath = %s, qb_savepath = %s,
qb_completed_datetime = IF(%s IS NOT NULL AND qb_completed_datetime IS NULL, %s, qb_completed_datetime), qb_completed_datetime =
IF(%s IS NOT NULL AND qb_completed_datetime IS NULL, %s, qb_completed_datetime),
qb_last_update = NOW() qb_last_update = NOW()
WHERE qb_hash = %s OR torrent_hash = %s WHERE qb_hash = %s OR torrent_hash = %s
""" """
progress_pct = float(t.progress) * 100.0
cursor.execute( cursor.execute(sql, (
sql, t.hash,
( t.state,
t.hash, # COALESCE(qb_hash, %s) float(t.progress) * 100.0,
t.state, getattr(t, "save_path", None),
progress_pct, completion_dt,
getattr(t, "save_path", None), completion_dt,
completion_dt, t.hash,
completion_dt, t.hash
t.hash, # WHERE qb_hash = %s ))
t.hash, # OR torrent_hash = %s
),
)
def handle_completed_and_stalled():
""" # ==============================
- Completed torrenty: odstraní z qB (bez smazání dat), v DB označí jako completed. # 🧹 HANDLE COMPLETED + DEAD TORRENTS
- Dlouho stalled torrenty: odstraní z qB (včetně nedokončených dat), v DB označí jako stalled_removed. # ==============================
"""
def handle_completed_and_dead():
torrents = qb.torrents_info() torrents = qb.torrents_info()
completed_states = {"uploading", "stalledUP", "queuedUP", "pausedUP", "completed"}
stalled_states = {"stalledDL", "error", "missingFiles"}
for t in torrents: for t in torrents:
t_hash = t.hash t_hash = t.hash
state = t.state state = t.state
progress = float(t.progress) progress = float(t.progress)
time_active = getattr(t, "time_active", 0)
# 1) COMPLETED progress 100% nebo stav v completed_states # ==========================
if progress >= 1.0 or state in completed_states: # ✔ COMPLETED
print(f"✅ Completed torrent, removing from qB (keeping data): {t.name}") # ==========================
if progress >= 1.0 or state in {"completed", "uploading", "stalledUP", "queuedUP"}:
print(f"✅ Completed torrent → remove (keep data): {t.name}")
try: try:
qb.torrents_delete( qb.torrents_delete(torrent_hashes=t_hash, delete_files=False)
torrent_hashes=t_hash,
delete_files=False, # data necháme
)
except Exception as e: except Exception as e:
print(f"⚠️ Could not remove completed torrent {t_hash}: {e}") print("⚠️ delete failed:", e)
sql = """ cursor.execute("""
UPDATE torrents UPDATE torrents
SET SET qb_state='completed',
qb_state = 'completed', qb_progress=100,
qb_progress = 100, qb_completed_datetime=NOW(),
qb_completed_datetime = IF(qb_completed_datetime IS NULL, NOW(), qb_completed_datetime), qb_last_update=NOW()
qb_last_update = NOW() WHERE qb_hash=%s OR torrent_hash=%s
WHERE qb_hash = %s OR torrent_hash = %s """, (t_hash, t_hash))
"""
cursor.execute(sql, (t_hash, t_hash))
continue continue
# 2) STALLED dlouho # ==========================
if state in stalled_states and time_active and time_active > STALLED_MAX_SECONDS: # ❌ DEAD TORRENT (never seen_complete)
print(f"⛔ Stalled torrent for too long, removing (with data): {t.name}") # ==========================
try:
qb.torrents_delete(
torrent_hashes=t_hash,
delete_files=True, # nedokončená data smažeme
)
except Exception as e:
print(f"⚠️ Could not remove stalled torrent {t_hash}: {e}")
sql = """ props = qb.torrents_properties(t_hash)
UPDATE torrents seen = getattr(props, "last_seen", 0)
SET
qb_state = 'stalled_removed',
qb_last_update = NOW()
WHERE qb_hash = %s OR torrent_hash = %s
"""
cursor.execute(sql, (t_hash, t_hash))
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(): def count_active_downloads():
"""Return number of torrents that are currently downloading/active.""" torrents = qb.torrents_info(filter="all")
torrents = qb.torrents_info() return sum(1 for t in torrents if float(t.progress) < 1.0)
active_states = {
"downloading",
"stalledDL",
"queuedDL",
"checkingDL",
"allocating",
"metaDL",
"forcedDL",
}
count = sum(1 for t in torrents if t.state in active_states)
return count
# ==============================
# ENQUEUE NEW TORRENTS
# ==============================
def enqueue_new_torrents(): def enqueue_new_torrents():
"""
Select from DB torrents that:
- have torrent_content (BLOB)
- qb_added = 0 or NULL
and add them to qBittorrent, up to free slots.
"""
active = count_active_downloads() active = count_active_downloads()
print("DEBUG active =", active)
if active >= MAX_ACTIVE_DOWNLOADS: if active >= MAX_ACTIVE_DOWNLOADS:
print(f"📦 Active downloads: {active} (max {MAX_ACTIVE_DOWNLOADS}) → no new torrents enqueued.") print(f"📦 {active}/{MAX_ACTIVE_DOWNLOADS} active → no enqueue")
return return
slots = MAX_ACTIVE_DOWNLOADS - active slots = MAX_ACTIVE_DOWNLOADS - active
print(f"🪣 Active downloads: {active}, free slots: {slots}")
sql = """ sql = """
SELECT id, torrent_hash, torrent_content, torrent_filename SELECT id, torrent_hash, torrent_content, torrent_filename, added_datetime
FROM torrents FROM torrents
WHERE (qb_added IS NULL OR qb_added = 0) WHERE (qb_added IS NULL OR qb_added = 0)
AND torrent_content IS NOT NULL AND torrent_content IS NOT NULL
ORDER BY added_datetime ASC ORDER BY added_datetime DESC -- <── take NEWEST FIRST
LIMIT %s LIMIT %s
""" """
cursor.execute(sql, (slots,)) cursor.execute(sql, (slots,))
rows = cursor.fetchall() rows = cursor.fetchall()
if not rows: if not rows:
print(" No new torrents in DB to enqueue.") print(" No new torrents")
return return
for row in rows: for row in rows:
t_id = row["id"] t_id = row["id"]
t_hash = row["torrent_hash"] t_hash = row["torrent_hash"]
blob = row["torrent_content"] blob = row["torrent_content"]
filename = row.get("torrent_filename") or "unknown.torrent" filename = row.get("torrent_filename", "unknown.torrent")
if not blob: if not blob:
print(f"⚠️ Torrent id={t_id} hash={t_hash} has no content, skipping.") print("⚠️ empty blob, skip")
continue continue
print(f" Enqueuing torrent to qB: {filename} ({t_hash})") # ==========================
# 🧪 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: try:
qb.torrents_add( qb.torrents_add(torrent_files=blob, savepath=DEFAULT_SAVE_PATH)
torrent_files=blob,
savepath=DEFAULT_SAVE_PATH,
)
except Exception as e: except Exception as e:
print(f"❌ Failed to add torrent {t_hash} to qBittorrent:", e) print(f"❌ Failed to add {t_hash}: {e}")
# můžeš si zde označit v DB jako error, pokud chceš
continue continue
# Označíme v DB jako přidaný (qb_added=1), qb_hash=t_hash cursor.execute("""
sql_update = """
UPDATE torrents UPDATE torrents
SET SET qb_added=1,
qb_added = 1, qb_hash=COALESCE(qb_hash, %s),
qb_hash = COALESCE(qb_hash, %s), qb_state='added',
qb_state = 'added', qb_last_update=NOW()
qb_last_update = NOW() WHERE id=%s
WHERE id = %s """, (t_hash, t_id))
"""
cursor.execute(sql_update, (t_hash, t_id))
# ============================== # ==============================
# 🏁 MAIN LOOP # 🏁 MAIN LOOP
# ============================== # ==============================
print("🚀 Torrent worker started. Press Ctrl+C to stop.\n") print("🚀 Worker started")
try: try:
while True: while True:
loop_start = datetime.now() print(f"\n⏱ Loop {datetime.now():%Y-%m-%d %H:%M:%S}")
print(f"⏱️ Loop start: {loop_start.strftime('%Y-%m-%d %H:%M:%S')}")
try: sync_qb_to_db()
# 1) Sync from qB → DB handle_completed_and_dead()
sync_qb_to_db() enqueue_new_torrents()
# 2) Handle completed & stalled torrents (remove from qB, mark in DB) print(f"🛌 Sleep {LOOP_SLEEP_SECONDS}s\n")
handle_completed_and_stalled()
# 3) Enqueue new torrents from DB (up to MAX_ACTIVE_DOWNLOADS)
enqueue_new_torrents()
except Exception as e:
print(f"💥 Error in main loop: {e}")
print(f"🛌 Sleeping {LOOP_SLEEP_SECONDS} seconds...\n")
time.sleep(LOOP_SLEEP_SECONDS) time.sleep(LOOP_SLEEP_SECONDS)
except KeyboardInterrupt: except KeyboardInterrupt:
print("🛑 Stopping worker (Ctrl+C).") print("🛑 Stopping worker...")
finally: finally:
try: db.close()
db.close()
except Exception:
pass
print("👋 Bye.") print("👋 Bye.")

72
60 Testcountoftorrents.py Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime
import qbittorrentapi
# ==============================
# CONFIG přizpůsob si podle sebe
# ==============================
QBT_CONFIG = {
"host": "192.168.1.76",
"port": 8080,
"username": "admin",
"password": "adminadmin",
}
def fmt_ts(ts: int) -> str:
"""
Převod unix timestampu na čitelný string.
qBittorrent vrací -1 pokud hodnota není známá.
"""
if ts is None or ts <= 0:
return ""
try:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return f"invalid({ts})"
def main():
# Připojení
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\n")
except Exception as e:
print("❌ Could not connect to qBittorrent:", e)
return
# Všechno, žádný filter na downloading
torrents = qb.torrents_info(filter='all')
print(f"Found {len(torrents)} torrents (filter='all')\n")
for t in torrents:
# properties obsahují last_seen
try:
props = qb.torrents_properties(t.hash)
except Exception as e:
print(f"⚠️ Cannot get properties for {t.hash[:8]} {t.name}: {e}")
continue
seen_complete = getattr(t, "seen_complete", None) # z /torrents/info
last_seen = getattr(props, "last_seen", None) # z /torrents/properties
print("=" * 80)
print(f"Name : {t.name}")
print(f"Hash : {t.hash}")
print(f"State : {t.state}")
print(f"Progress : {float(t.progress) * 100:.2f}%")
print(f"Seen complete: {fmt_ts(seen_complete)} (t.seen_complete)")
print(f"Last seen : {fmt_ts(last_seen)} (props.last_seen)")
print("\n✅ Done.")
if __name__ == "__main__":
main()