278 lines
9.9 KiB
Python
278 lines
9.9 KiB
Python
"""
|
|
nas_dl.py — Anade descargas al NAS Synology via API de Download Station.
|
|
Usado por pi_bot.py (comando 'descargar') y por videoclub.py.
|
|
|
|
Login: POST /webapi/auth.cgi (SYNO.API.Auth, version 6, session DownloadStation)
|
|
Crear: POST /webapi/DownloadStation/task.cgi (SYNO.DownloadStation.Task, create)
|
|
|
|
La URI puede ser un enlace HTTP directo (p. ej. enlace de RealDebrid),
|
|
un magnet o una URL de fichero .torrent.
|
|
"""
|
|
import json
|
|
import os
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
# Carga .env del directorio del script si existe (no requiere python-dotenv)
|
|
BASE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
def _load_env(path):
|
|
env = {}
|
|
try:
|
|
for line in open(path):
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
k, v = line.split("=", 1)
|
|
env[k.strip()] = v.strip()
|
|
except Exception:
|
|
pass
|
|
return env
|
|
|
|
|
|
ENV = _load_env(os.path.join(BASE, ".env"))
|
|
# Respaldo: si no hay credenciales NAS aqui, probar el .env de videoclub/
|
|
_VIDEOCLUB_ENV = _load_env(os.path.join(BASE, "..", "videoclub", ".env"))
|
|
for _k in ("NAS_HOST", "NAS_PORT", "NAS_USER", "NAS_PASS", "NAS_HTTPS",
|
|
"TORRENTIO_RD_TOKEN"):
|
|
if not ENV.get(_k):
|
|
ENV[_k] = _VIDEOCLUB_ENV.get(_k, "")
|
|
NAS_HOST = os.getenv("NAS_HOST", ENV.get("NAS_HOST", "192.168.50.31")).strip().rstrip("/")
|
|
NAS_PORT = os.getenv("NAS_PORT", ENV.get("NAS_PORT", "5000")).strip()
|
|
NAS_USER = os.getenv("NAS_USER", ENV.get("NAS_USER", "")).strip()
|
|
NAS_PASS = os.getenv("NAS_PASS", ENV.get("NAS_PASS", "")).strip()
|
|
NAS_HTTPS = os.getenv("NAS_HTTPS", ENV.get("NAS_HTTPS", "0")).strip() == "1"
|
|
RD_TOKEN = os.getenv("TORRENTIO_RD_TOKEN", ENV.get("TORRENTIO_RD_TOKEN", "")).strip()
|
|
|
|
BASE_URL = f"{'https' if NAS_HTTPS else 'http'}://{NAS_HOST}:{NAS_PORT}"
|
|
|
|
|
|
def unrestrict_rd(uri):
|
|
"""Convierte un enlace real-debrid.com/d/XXX en el enlace directo descargable.
|
|
Si el enlace no es de RealDebrid, lo devuelve tal cual."""
|
|
if "real-debrid.com/" not in uri:
|
|
return uri
|
|
if not RD_TOKEN:
|
|
print("[RD] Falta TORRENTIO_RD_TOKEN en .env")
|
|
return uri
|
|
try:
|
|
data = urllib.parse.urlencode({"link": uri}).encode()
|
|
req = urllib.request.Request(
|
|
"https://api.real-debrid.com/rest/1.0/unrestrict/link",
|
|
data=data, headers={"Authorization": f"Bearer {RD_TOKEN}"})
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
d = json.loads(r.read().decode("utf-8"))
|
|
directo = d.get("download")
|
|
if directo:
|
|
print(f"[RD] Desrestrict OK: {directo[:80]}")
|
|
return directo
|
|
print(f"[RD] No devolvio enlace directo: {d.get('error', d)}")
|
|
except Exception as e:
|
|
print(f"[RD] Error desrestrict: {e}")
|
|
return uri
|
|
|
|
|
|
def _api(path, params, session=None):
|
|
"""Llama a la API del NAS. DSM 7 exige GET con `_sid` en la query string.
|
|
Si `session` viene como 'sid=<x>' se extrae y se anade a la query."""
|
|
sid = None
|
|
if session:
|
|
if session.startswith("sid="):
|
|
sid = session[4:]
|
|
elif session.startswith("_sid="):
|
|
sid = session[5:]
|
|
if sid:
|
|
params = dict(params)
|
|
params["_sid"] = sid
|
|
qs = urllib.parse.urlencode(params)
|
|
req = urllib.request.Request(f"{BASE_URL}{path}?{qs}",
|
|
headers={"Cookie": session or ""})
|
|
with urllib.request.urlopen(req, timeout=20) as r:
|
|
return json.loads(r.read().decode())
|
|
|
|
|
|
def login():
|
|
"""Devuelve la cookie de sesion DownloadStation o None."""
|
|
if not NAS_USER or not NAS_PASS:
|
|
print("[NAS] Faltan NAS_USER/NAS_PASS en .env")
|
|
return None
|
|
try:
|
|
r = _api("/webapi/auth.cgi", {
|
|
"api": "SYNO.API.Auth",
|
|
"version": "6",
|
|
"method": "login",
|
|
"account": NAS_USER,
|
|
"passwd": NAS_PASS,
|
|
"session": "DownloadStation",
|
|
"format": "cookie",
|
|
})
|
|
if r.get("success"):
|
|
sid = r.get("data", {}).get("sid")
|
|
return f"sid={sid}" if sid else None
|
|
print(f"[NAS] Login fallido: {r.get('error')}")
|
|
except Exception as e:
|
|
print(f"[NAS] Error de login: {e}")
|
|
return None
|
|
|
|
|
|
def add_torrent(uri, session=None):
|
|
"""Anade una URI (http/magnet/.torrent) a Download Station.
|
|
Si es enlace de RealDebrid, primero se desrestricta a enlace directo."""
|
|
uri = unrestrict_rd(uri)
|
|
if not session:
|
|
session = login()
|
|
if not session:
|
|
return False
|
|
try:
|
|
r = _api("/webapi/DownloadStation/task.cgi", {
|
|
"api": "SYNO.DownloadStation.Task",
|
|
"version": "3",
|
|
"method": "create",
|
|
"uri": uri,
|
|
}, session=session)
|
|
if r.get("success"):
|
|
print(f"[NAS] Tarea anadida: {uri[:80]}")
|
|
return True
|
|
print(f"[NAS] Error al anadir tarea: {r.get('error')}")
|
|
except Exception as e:
|
|
print(f"[NAS] Error: {e}")
|
|
return False
|
|
|
|
|
|
def human_size(n):
|
|
"""Formatea bytes a una unidad legible."""
|
|
n = float(n or 0)
|
|
for u in ("B", "KB", "MB", "GB", "TB"):
|
|
if n < 1024:
|
|
return f"{n:.1f}{u}"
|
|
n /= 1024
|
|
return f"{n:.1f}PB"
|
|
|
|
|
|
def progress_bar(percent, width=12):
|
|
"""Genera una barra de progreso compacta para mensajes de Telegram."""
|
|
percent = max(0.0, min(100.0, float(percent or 0)))
|
|
filled = int(round(percent * width / 100))
|
|
return "█" * filled + "░" * (width - filled)
|
|
|
|
|
|
def listar_descargas(hablar_solo_activas=True, session=None):
|
|
"""Devuelve (lineas_texto, activas). Lista las tareas de Download Station
|
|
con estado, porcentaje de progreso, tamaño descargado y velocidad.
|
|
`progress` se calcula con downloaded_pieces/total_pieces (fiable en NAS)."""
|
|
if not session:
|
|
session = login()
|
|
if not session:
|
|
return ["❌ No se pudo autenticar con Download Station."], []
|
|
try:
|
|
r = _api("/webapi/DownloadStation/task.cgi", {
|
|
"api": "SYNO.DownloadStation.Task",
|
|
"version": "3",
|
|
"method": "list",
|
|
"additional": "detail,transfer",
|
|
}, session=session)
|
|
if not r.get("success"):
|
|
return [f"❌ Error al listar: {r.get('error')}"], []
|
|
except Exception as e:
|
|
return [f"❌ Error al consultar el NAS: {e}"], []
|
|
tareas = r.get("data", {}).get("tasks", []) or []
|
|
lineas, activas, resto = [], [], []
|
|
for t in tareas:
|
|
titulo = t.get("title") or ""
|
|
estado = t.get("status") or "?"
|
|
detail = (t.get("additional") or {}).get("detail") or {}
|
|
transfer = (t.get("additional") or {}).get("transfer") or {}
|
|
total_pieces = detail.get("total_pieces") or 0
|
|
done_pieces = transfer.get("downloaded_pieces") or 0
|
|
pct = (done_pieces * 100.0 / total_pieces) if total_pieces else 0
|
|
size = transfer.get("size_downloaded") or 0
|
|
speed = transfer.get("speed_download") or 0
|
|
if estado == "downloading":
|
|
activas.append(f"▶️ <b>{progress_bar(pct)} {pct:.1f}%</b> {human_size(size)} ⬇️{speed // 1024} KB/s\n"
|
|
f" {titulo}")
|
|
else:
|
|
resto.append((estado, pct, size, titulo))
|
|
if activas:
|
|
lineas.append("📥 <b>DESCARGANDO</b>")
|
|
lineas.extend(activas)
|
|
lineas.append("")
|
|
if not hablar_solo_activas:
|
|
lineas.append("📁 Otras tareas:")
|
|
for estado, pct, size, titulo in resto:
|
|
icono = {"finished": "✅", "error": "❌", "paused": "⏸️",
|
|
"seeding": "🌱"}.get(estado, "•")
|
|
if estado == "finished":
|
|
lineas.append(f"{icono} {titulo}")
|
|
else:
|
|
lineas.append(f"{icono} {estado} ({pct:.0f}%) — {titulo}")
|
|
if not activas:
|
|
lineas.append("No hay torrents descargándose ahora mismo.")
|
|
return lineas, activas
|
|
|
|
|
|
def listar_tareas(session=None):
|
|
"""Devuelve todas las tareas de Download Station con id y estado,
|
|
para poder borrar una concreta desde Telegram."""
|
|
if not session:
|
|
session = login()
|
|
if not session:
|
|
return []
|
|
try:
|
|
r = _api("/webapi/DownloadStation/task.cgi", {
|
|
"api": "SYNO.DownloadStation.Task",
|
|
"version": "3",
|
|
"method": "list",
|
|
"additional": "detail,transfer",
|
|
}, session=session)
|
|
if not r.get("success"):
|
|
return []
|
|
except Exception as e:
|
|
print(f"[NAS] Error listar_tareas: {e}")
|
|
return []
|
|
salida = []
|
|
for t in (r.get("data", {}).get("tasks", []) or []):
|
|
detail = (t.get("additional") or {}).get("detail") or {}
|
|
transfer = (t.get("additional") or {}).get("transfer") or {}
|
|
total = detail.get("total_pieces") or 0
|
|
done = transfer.get("downloaded_pieces") or 0
|
|
pct = (done * 100.0 / total) if total else 0.0
|
|
salida.append({
|
|
"id": t.get("id"),
|
|
"title": t.get("title") or "",
|
|
"status": t.get("status") or "?",
|
|
"pct": pct,
|
|
"size": transfer.get("size_downloaded") or 0,
|
|
})
|
|
return salida
|
|
|
|
|
|
def borrar_tarea(tid, session=None):
|
|
"""Borra una tarea de Download Station por su id.
|
|
Devuelve (ok, detalle)."""
|
|
if not session:
|
|
session = login()
|
|
if not session:
|
|
return False, "No se pudo autenticar con Download Station."
|
|
try:
|
|
import json as _json
|
|
r = _api("/webapi/DownloadStation/task.cgi", {
|
|
"api": "SYNO.DownloadStation.Task",
|
|
"version": "3",
|
|
"method": "delete",
|
|
"id": _json.dumps([tid]),
|
|
}, session=session)
|
|
except Exception as e:
|
|
return False, str(e)
|
|
if r.get("success"):
|
|
return True, "ok"
|
|
return False, str(r.get("error"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
if len(sys.argv) < 2:
|
|
print("Uso: python nas_dl.py <uri|magnet|enlace>")
|
|
sys.exit(1)
|
|
ok = add_torrent(sys.argv[1])
|
|
sys.exit(0 if ok else 1)
|