Ctrl K
ComunidadGuías…

nuke_stalled.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Reap stalled qBittorrent torrents (`stalledDL`/`metaDL` active longer than STALL_THRESHOLD_SEC)
and their Radarr/Sonarr queue entries. Runs every minute via cron.

Removes and blocklists from the originating Arr; deletes directly from qBit only if neither Arr
claims the hash. QBIT_USER empty means unauthenticated (bypass-auth-on-LAN); set with a failed
login exits 1, so an auth problem shows in the cron log instead of stalled torrents silently
piling up.
"""

from __future__ import annotations
import os
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from media_stack.paths import (  # noqa: E402
    MEDIA_STACK_ROOT, SONARR_URL, RADARR_URL, QBIT_URL, load_env_file,
)
from media_stack.clients.arr import ArrClient  # noqa: E402
from media_stack.clients.qbit import QBitClient  # noqa: E402


ENV_FILE = MEDIA_STACK_ROOT / ".env"


def _build_env() -> dict[str, str]:
    return {**load_env_file(ENV_FILE), **os.environ}


def _required(env: dict[str, str], key: str) -> str:
    val = env.get(key, "")
    if not val:
        sys.exit(f"missing required env: {key} (set in {ENV_FILE})")
    return val


def build_clients(env: dict[str, str] | None = None) -> tuple[QBitClient, ArrClient, ArrClient]:
    """Not run at import time, so tests can import this module without live services."""
    env = env if env is not None else _build_env()
    sonarr_key = _required(env, "SONARR_API_KEY")
    radarr_key = _required(env, "RADARR_API_KEY")
    qbit_user  = env.get("QBIT_USER", "")
    qbit_pass  = env.get("QBIT_PASS", "")
    qbit = QBitClient(QBIT_URL, qbit_user, qbit_pass)
    if not qbit.login():
        sys.exit(
            f"FATAL: qBit login to {QBIT_URL} failed. "
            f"Check QBIT_USER/QBIT_PASS in .env (or unset QBIT_USER to use "
            f"bypass-auth-on-LAN). Refusing to run unauthenticated when "
            f"credentials are configured — would silently mask real failures."
        )
    return qbit, ArrClient(SONARR_URL, sonarr_key), ArrClient(RADARR_URL, radarr_key)


def nuke_all_stalled(qbit: QBitClient, sonarr: ArrClient, radarr: ArrClient,
                     *, stall_threshold_sec: int = 300) -> int:
    """0 on success (including no torrents), 2 if qBit can't be listed, so cron sees the missed pass."""
    try:
        torrents = qbit.torrents_info()
    except Exception as e:
        print(f"Error fetching qBit torrents: {e}", file=sys.stderr)
        return 2

    if not torrents:
        print("No torrents in qBittorrent.")
        return 0

    STALL_THRESHOLD_SEC = stall_threshold_sec
    for t in torrents:
        name = t["name"]
        state = t["state"]
        active_time = t["time_active"]
        t_hash = t["hash"]

        if state in ("stalledDL", "metaDL") and active_time > STALL_THRESHOLD_SEC:
            print(f"\nTargeting stalled torrent: {name}")
            print(f"State: {state}, Active Time: {active_time}s")
            # "not_found" is safe to fall through to a direct qBit delete; "matched but
            # DELETE failed" is not (it would skip blocklisting and re-grab the release).
            radarr_r = radarr.remove_by_download_id(t_hash)
            sonarr_r = sonarr.remove_by_download_id(t_hash)
            if "removed" in (radarr_r, sonarr_r):
                pass
            elif "delete_failed" in (radarr_r, sonarr_r):
                # leave it for the next tick; direct delete would skip blocklisting
                print(f"  ERROR: Arr DELETE failed for hash={t_hash} "
                      f"(radarr={radarr_r} sonarr={sonarr_r}); "
                      f"leaving torrent in place for retry.")
            elif "queue_error" in (radarr_r, sonarr_r):
                # can't be sure the torrent isn't tracked there, so don't delete directly
                print(f"  WARN: Arr queue lookup failed for hash={t_hash} "
                      f"(radarr={radarr_r} sonarr={sonarr_r}); "
                      f"leaving torrent in place for retry.")
            else:
                print(f"Not in Arr queues — deleting from qBit directly: {name}")
                try:
                    qbit.delete_torrent(t_hash, delete_files=True)
                except Exception as e:
                    print(f"  ERROR: qBit delete failed for hash={t_hash} name={name!r}: {e}")
        elif state == "downloading":
            print(f"Active download: {name} ({t['progress']*100:.1f}%)")
    return 0


def main() -> int:
    env = _build_env()
    stall_threshold = int(env.get("STALL_THRESHOLD_SEC", "300"))
    qbit, sonarr, radarr = build_clients(env)
    return nuke_all_stalled(qbit, sonarr, radarr, stall_threshold_sec=stall_threshold)


if __name__ == "__main__":
    sys.exit(main())