Compare commits

7 Commits

Author SHA1 Message Date
058281df0e Merge pull request 'Integrate dead torrent cleanup into Manager loop' (#1) from claude/beautiful-taussig into master 2026-03-18 13:59:17 +01:00
3ce8fa0080 Integrate dead torrent cleanup into Manager loop
Move dead torrent detection logic from 50 MrtveTorrenty.py into 70 Manager.py as step 1b, so the manager handles completed, dead, and new torrents in a single run cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:54:35 +01:00
695d7167ab Merge remote-tracking branch 'gitea/master' 2026-03-08 10:26:49 +01:00
1aa10c92d6 reporter 2026-03-08 10:26:42 +01:00
c4f2d8b13d git 2026-03-06 17:25:47 +01:00
a74ad8ff00 git 2026-03-06 17:22:02 +01:00
afbca5b348 z230 2026-03-06 14:24:32 +01:00
4 changed files with 483 additions and 5 deletions

View File

@@ -14,12 +14,16 @@ Oba klienti sdílí stejnou DB frontu. Torrent "nárokovaný" jedním klientem
import pymysql import pymysql
import qbittorrentapi import qbittorrentapi
import sys import sys
from datetime import datetime from datetime import datetime, timedelta
# ============================================================ # ============================================================
# CONFIG # 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 = [ CLIENTS = [
{ {
"name": "UltraCC Seedbox", "name": "UltraCC Seedbox",
@@ -142,6 +146,68 @@ def handle_completed(qbt, cursor):
return removed 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 # STEP 2: Count active (non-completed) torrents in qBittorrent
# ============================================================ # ============================================================
@@ -230,6 +296,14 @@ def process_client(client_cfg: dict, cursor):
if removed == 0: if removed == 0:
print(f" │ Žádné dokončené.") 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ů # Krok 2: Stav slotů
active_hashes = get_active_hashes(qbt) active_hashes = get_active_hashes(qbt)
active = len(active_hashes) active = len(active_hashes)

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Projde /mnt/user/torrents/ultracc, pro každý soubor spočítá blake3
a porovná s tabulkou file_md5_index. Pokud je hash nalezen → soubor smaže.
Po smazání souborů odstraní prázdné adresáře.
"""
import os
import sys
import blake3
import pymysql
import paramiko
from pathlib import Path
# ============================================================
# CONFIG
# ============================================================
SCAN_DIR = "//tower/torrents/ultracc2"
SSH_CONFIG = {
"hostname": "192.168.1.76",
"port": 22,
"username": "root",
"password": "7309208104",
}
ULTRACC_DIRS = [
"/mnt/user/Torrents/UltraCC",
"/mnt/user/Torrents/UltraCC1",
"/mnt/user/Torrents/UltraCC2",
]
DB_CONFIG = {
"host": "192.168.1.76",
"port": 3306,
"user": "root",
"password": "Vlado9674+",
"database": "torrents",
"charset": "utf8mb4",
}
CHUNK_SIZE = 8 * 1024 * 1024 # 8 MB
DRY_RUN = False # True = pouze vypíše, nesmaže
# ============================================================
# HELPERS
# ============================================================
def compute_blake3(path: Path) -> bytes:
"""Vrátí blake3 digest jako 32 raw bytes."""
h = blake3.blake3()
with open(path, "rb") as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk:
break
h.update(chunk)
return h.digest()
def hash_in_db(cursor, digest: bytes):
"""Vrátí (host_name, full_path) prvního záznamu s daným hashem, nebo None."""
cursor.execute(
"SELECT host_name, full_path FROM file_md5_index WHERE blake3 = %s AND host_name = 'tower1' AND full_path LIKE '/mnt/user/#ColdData/Porno/%%' LIMIT 1",
(digest,)
)
return cursor.fetchone() # None nebo (host_name, full_path)
def remove_empty_dirs(root: str) -> int:
"""Rekurzivně smaže prázdné adresáře pod root. Vrátí počet smazaných."""
removed = 0
for dirpath, dirnames, filenames in os.walk(root, topdown=False):
if dirpath == root:
continue
try:
os.rmdir(dirpath)
print(f" [rmdir] {dirpath}")
removed += 1
except OSError:
pass
return removed
# ============================================================
# MAIN
# ============================================================
def set_ultracc_permissions():
"""Přes SSH nastaví na Tower chown nobody:users + chmod 777 pro všechny UltraCC adresáře."""
print("Nastavuji práva na Tower (UltraCC*)...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(**SSH_CONFIG)
for d in ULTRACC_DIRS:
_, out, err = ssh.exec_command(
'chown -R nobody:users "%s" && chmod -R 777 "%s" && echo OK' % (d, d)
)
result = out.read().decode().strip()
error = err.read().decode().strip()
if result == "OK":
print(f" [OK] {d}")
else:
print(f" [CHYBA] {d}: {error}")
ssh.close()
print()
def main():
dry_run = DRY_RUN
set_ultracc_permissions()
if dry_run:
print("=== DRY RUN — nic se nesmaže ===\n")
conn = pymysql.connect(**DB_CONFIG)
cursor = conn.cursor()
scan_root = Path(SCAN_DIR)
if not scan_root.exists():
print(f"CHYBA: Adresář neexistuje: {SCAN_DIR}")
sys.exit(1)
files_checked = 0
files_deleted = 0
files_kept = 0
bytes_deleted = 0
for file_path in scan_root.rglob("*"):
if not file_path.is_file():
continue
files_checked += 1
size = file_path.stat().st_size
try:
digest = compute_blake3(file_path)
except OSError as e:
print(f" [CHYBA čtení] {file_path}: {e}")
continue
db_match = hash_in_db(cursor, digest)
if db_match:
db_host, db_path = db_match
print(f" [SMAZAT] {file_path} ({size:,} B)")
print(f" ↳ originál v DB: [{db_host}] {db_path}")
if not dry_run:
try:
file_path.unlink()
files_deleted += 1
bytes_deleted += size
except OSError as e:
print(f" [CHYBA smazání] {file_path}: {e}")
else:
files_deleted += 1
bytes_deleted += size
else:
print(f" [zachovat] {file_path} ({size:,} B)")
files_kept += 1
cursor.close()
conn.close()
print()
print(f"Zkontrolováno: {files_checked} souborů")
print(f"Ke smazání: {files_deleted} souborů ({bytes_deleted / 1024**3:.2f} GB)")
print(f"Zachováno: {files_kept} souborů")
if not dry_run and files_deleted > 0:
print("\nOdstraňuji prázdné adresáře...")
removed = remove_empty_dirs(SCAN_DIR)
print(f"Odstraněno prázdných adresářů: {removed}")
if dry_run:
print("\n(Dry run — žádné změny nebyly provedeny)")
if __name__ == "__main__":
main()

View File

@@ -10,6 +10,7 @@ import os
import sys import sys
import blake3 import blake3
import pymysql import pymysql
import paramiko
from pathlib import Path from pathlib import Path
# ============================================================ # ============================================================
@@ -18,6 +19,19 @@ from pathlib import Path
SCAN_DIR = "//tower/torrents/ultracc" SCAN_DIR = "//tower/torrents/ultracc"
SSH_CONFIG = {
"hostname": "192.168.1.76",
"port": 22,
"username": "root",
"password": "7309208104",
}
ULTRACC_DIRS = [
"/mnt/user/Torrents/UltraCC",
"/mnt/user/Torrents/UltraCC1",
"/mnt/user/Torrents/UltraCC2",
]
DB_CONFIG = { DB_CONFIG = {
"host": "192.168.1.76", "host": "192.168.1.76",
"port": 3306, "port": 3306,
@@ -46,12 +60,13 @@ def compute_blake3(path: Path) -> bytes:
return h.digest() return h.digest()
def hash_in_db(cursor, digest: bytes) -> bool: def hash_in_db(cursor, digest: bytes):
"""Vrátí (host_name, full_path) prvního záznamu s daným hashem, nebo None."""
cursor.execute( cursor.execute(
"SELECT 1 FROM file_md5_index WHERE blake3 = %s LIMIT 1", "SELECT host_name, full_path FROM file_md5_index WHERE blake3 = %s AND host_name = 'tower1' AND full_path LIKE '/mnt/user/#ColdData/Porno/%%' LIMIT 1",
(digest,) (digest,)
) )
return cursor.fetchone() is not None return cursor.fetchone() # None nebo (host_name, full_path)
def remove_empty_dirs(root: str) -> int: def remove_empty_dirs(root: str) -> int:
@@ -73,9 +88,31 @@ def remove_empty_dirs(root: str) -> int:
# MAIN # MAIN
# ============================================================ # ============================================================
def set_ultracc_permissions():
"""Přes SSH nastaví na Tower chown nobody:users + chmod 777 pro všechny UltraCC adresáře."""
print("Nastavuji práva na Tower (UltraCC*)...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(**SSH_CONFIG)
for d in ULTRACC_DIRS:
_, out, err = ssh.exec_command(
'chown -R nobody:users "%s" && chmod -R 777 "%s" && echo OK' % (d, d)
)
result = out.read().decode().strip()
error = err.read().decode().strip()
if result == "OK":
print(f" [OK] {d}")
else:
print(f" [CHYBA] {d}: {error}")
ssh.close()
print()
def main(): def main():
dry_run = DRY_RUN dry_run = DRY_RUN
set_ultracc_permissions()
if dry_run: if dry_run:
print("=== DRY RUN — nic se nesmaže ===\n") print("=== DRY RUN — nic se nesmaže ===\n")
@@ -105,8 +142,11 @@ def main():
print(f" [CHYBA čtení] {file_path}: {e}") print(f" [CHYBA čtení] {file_path}: {e}")
continue continue
if hash_in_db(cursor, digest): db_match = hash_in_db(cursor, digest)
if db_match:
db_host, db_path = db_match
print(f" [SMAZAT] {file_path} ({size:,} B)") print(f" [SMAZAT] {file_path} ({size:,} B)")
print(f" ↳ originál v DB: [{db_host}] {db_path}")
if not dry_run: if not dry_run:
try: try:
file_path.unlink() file_path.unlink()

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Projde /mnt/user/torrents/ultracc, pro každý soubor spočítá blake3
a porovná s tabulkou file_md5_index. Pokud je hash nalezen → soubor smaže.
Po smazání souborů odstraní prázdné adresáře.
"""
import os
import sys
import blake3
import pymysql
import paramiko
from pathlib import Path
# ============================================================
# CONFIG
# ============================================================
SCAN_DIR = "//tower/torrents/ultracc1"
SSH_CONFIG = {
"hostname": "192.168.1.76",
"port": 22,
"username": "root",
"password": "7309208104",
}
ULTRACC_DIRS = [
"/mnt/user/Torrents/UltraCC",
"/mnt/user/Torrents/UltraCC1",
"/mnt/user/Torrents/UltraCC2",
]
DB_CONFIG = {
"host": "192.168.1.76",
"port": 3306,
"user": "root",
"password": "Vlado9674+",
"database": "torrents",
"charset": "utf8mb4",
}
CHUNK_SIZE = 8 * 1024 * 1024 # 8 MB
DRY_RUN = False # True = pouze vypíše, nesmaže
# ============================================================
# HELPERS
# ============================================================
def compute_blake3(path: Path) -> bytes:
"""Vrátí blake3 digest jako 32 raw bytes."""
h = blake3.blake3()
with open(path, "rb") as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk:
break
h.update(chunk)
return h.digest()
def hash_in_db(cursor, digest: bytes):
"""Vrátí (host_name, full_path) prvního záznamu s daným hashem, nebo None."""
cursor.execute(
"SELECT host_name, full_path FROM file_md5_index WHERE blake3 = %s AND host_name = 'tower1' AND full_path LIKE '/mnt/user/#ColdData/Porno/%%' LIMIT 1",
(digest,)
)
return cursor.fetchone() # None nebo (host_name, full_path)
def remove_empty_dirs(root: str) -> int:
"""Rekurzivně smaže prázdné adresáře pod root. Vrátí počet smazaných."""
removed = 0
for dirpath, dirnames, filenames in os.walk(root, topdown=False):
if dirpath == root:
continue
try:
os.rmdir(dirpath)
print(f" [rmdir] {dirpath}")
removed += 1
except OSError:
pass
return removed
# ============================================================
# MAIN
# ============================================================
def set_ultracc_permissions():
"""Přes SSH nastaví na Tower chown nobody:users + chmod 777 pro všechny UltraCC adresáře."""
print("Nastavuji práva na Tower (UltraCC*)...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(**SSH_CONFIG)
for d in ULTRACC_DIRS:
_, out, err = ssh.exec_command(
'chown -R nobody:users "%s" && chmod -R 777 "%s" && echo OK' % (d, d)
)
result = out.read().decode().strip()
error = err.read().decode().strip()
if result == "OK":
print(f" [OK] {d}")
else:
print(f" [CHYBA] {d}: {error}")
ssh.close()
print()
def main():
dry_run = DRY_RUN
set_ultracc_permissions()
if dry_run:
print("=== DRY RUN — nic se nesmaže ===\n")
conn = pymysql.connect(**DB_CONFIG)
cursor = conn.cursor()
scan_root = Path(SCAN_DIR)
if not scan_root.exists():
print(f"CHYBA: Adresář neexistuje: {SCAN_DIR}")
sys.exit(1)
files_checked = 0
files_deleted = 0
files_kept = 0
bytes_deleted = 0
for file_path in scan_root.rglob("*"):
if not file_path.is_file():
continue
files_checked += 1
size = file_path.stat().st_size
try:
digest = compute_blake3(file_path)
except OSError as e:
print(f" [CHYBA čtení] {file_path}: {e}")
continue
db_match = hash_in_db(cursor, digest)
if db_match:
db_host, db_path = db_match
print(f" [SMAZAT] {file_path} ({size:,} B)")
print(f" ↳ originál v DB: [{db_host}] {db_path}")
if not dry_run:
try:
file_path.unlink()
files_deleted += 1
bytes_deleted += size
except OSError as e:
print(f" [CHYBA smazání] {file_path}: {e}")
else:
files_deleted += 1
bytes_deleted += size
else:
print(f" [zachovat] {file_path} ({size:,} B)")
files_kept += 1
cursor.close()
conn.close()
print()
print(f"Zkontrolováno: {files_checked} souborů")
print(f"Ke smazání: {files_deleted} souborů ({bytes_deleted / 1024**3:.2f} GB)")
print(f"Zachováno: {files_kept} souborů")
if not dry_run and files_deleted > 0:
print("\nOdstraňuji prázdné adresáře...")
removed = remove_empty_dirs(SCAN_DIR)
print(f"Odstraněno prázdných adresářů: {removed}")
if dry_run:
print("\n(Dry run — žádné změny nebyly provedeny)")
if __name__ == "__main__":
main()