Files
Meisoftcoltd 9f3a640590 fix: Clean up supported download formats to prevent yt-dlp compatibility errors
Removed experimental video formats (`3gp`, `ts`, `m4v`) from `INPUT_TYPES` because `yt-dlp`'s `--merge-output-format` does not support them without explicit remuxing.
Added `mka` to the `is_audio` check array to ensure it is properly recognized as an audio format.
2026-04-24 14:51:44 +00:00

417 lines
20 KiB
Python

import os
import sys
import subprocess
import shutil
import time
import importlib.util
from pathlib import Path
from typing import Tuple
# === AUTO-INYECCIÓN DE DENO EN EL PATH (Vital para WSL2) ===
USER_HOME = Path.home()
deno_path = str(USER_HOME / ".deno" / "bin")
if deno_path not in os.environ.get("PATH", ""):
os.environ["PATH"] = f"{deno_path}:{os.environ.get('PATH', '')}"
def install_missing_requirements():
# 🚀 FIX: Mapeo exacto entre el nombre en pip y el módulo interno del plugin
requirements = [
("yt-dlp", "yt_dlp"),
("curl-cffi", "curl_cffi"),
("numpy", "numpy"),
("opencv-python", "cv2"),
("websockets", "websockets"),
("playwright", "playwright"),
("yt-dlp-ejs", "yt_dlp_plugins.extractor.ejs"),
("bgutil-ytdlp-pot-provider", "yt_dlp_plugins.extractor.getpot_bgutil")
]
missing = []
for pkg, imp in requirements:
try:
if importlib.util.find_spec(imp) is None:
missing.append(pkg)
except ModuleNotFoundError:
missing.append(pkg)
if missing:
print(f"📥 ComfyUI-Ytdpl: Instalando dependencias faltantes: {missing}")
try:
# 🚀 FIX: Pasamos el array limpio 'missing' sin forzar sufijos problemáticos
subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", *missing])
print("✅ Dependencias instaladas correctamente.")
if "playwright" in missing:
print("📥 Instalando navegadores de Playwright...")
subprocess.check_call([sys.executable, "-m", "playwright", "install", "chromium"])
print("✅ Navegadores de Playwright instalados.")
except Exception as e:
print(f"❌ Error al instalar dependencias: {e}")
# Ejecutar verificación de requisitos al importar el nodo
install_missing_requirements()
def get_cookies_interactively(url, save_path):
from playwright.sync_api import sync_playwright
import platform
print("\n" + "="*60)
print("🌐 [AUTO-COOKIE] Abriendo navegador para intervención manual...")
print(f"🖥️ Sistema detectado: {platform.system()}")
print("👉 POR FAVOR: Resuelve el Captcha o inicia sesión en la ventana.")
print("👉 IMPORTANTE: Cuando el video empiece a reproducirse, CIERRA LA VENTANA.")
print("="*60 + "\n")
try:
with sync_playwright() as p:
# En Windows/Mac abrirá su ventana nativa. En Linux usará X11/Wayland/WSLg.
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(url)
# Esperar a que el usuario cierre la ventana
try:
page.wait_for_event("close", timeout=0)
except Exception:
pass # Ventana cerrada por el usuario
print("⚙️ [AUTO-COOKIE] Extrayendo sesión...")
cookies = context.cookies()
# Convertir a formato Netscape (yt-dlp compatible)
with open(save_path, 'w', encoding='utf-8') as f:
f.write("# Netscape HTTP Cookie File\n")
for c in cookies:
domain = c.get('domain', '')
include_subdomains = 'TRUE' if domain.startswith('.') else 'FALSE'
path_val = c.get('path', '/')
secure = 'TRUE' if c.get('secure', False) else 'FALSE'
expires = str(int(c.get('expires', 0)))
name = c.get('name', '')
value = c.get('value', '')
f.write(f"{domain}\t{include_subdomains}\t{path_val}\t{secure}\t{expires}\t{name}\t{value}\n")
browser.close()
print("✅ [AUTO-COOKIE] Cookies guardadas con éxito. Reintentando descarga...")
return True
except Exception as e:
print(f"\n⚠️ [AUTO-COOKIE] Interfaz gráfica no disponible: {e}")
print("👉 (Esto es normal en servidores remotos sin pantalla o configuraciones estrictas).")
return False
class YTDLPVideoDownloader:
def __init__(self):
self.base_input_path = Path(__file__).parent.parent.parent / "input"
self.cookies_dir = self.base_input_path / "cookies"
self.cookies_dir.mkdir(parents=True, exist_ok=True)
self.output_dir = Path(__file__).parent.parent.parent / "output" / "ytdpl"
self.output_dir.mkdir(parents=True, exist_ok=True)
@classmethod
def INPUT_TYPES(cls):
cookies_folder = Path(__file__).parent.parent.parent / "input" / "cookies"
cookies_folder.mkdir(parents=True, exist_ok=True)
cookie_files = [f.name for f in cookies_folder.glob("*.txt")]
if not cookie_files: cookie_files = ["Ninguno"]
# Contenedores 100% compatibles con --merge-output-format y --audio-format
formats = [
"mp4", "mkv", "webm", "mov", "avi", "flv",
"mp3", "m4a", "wav", "flac", "ogg", "opus", "aac", "mka"
]
return {
"required": {
"url": ("STRING", {"multiline": False, "default": ""}),
"cookies_text": ("STRING", {"multiline": True, "default": ""}),
"cookies_file": (cookie_files, ),
"browser_source": (["Ninguno", "Chrome", "Firefox", "Safari", "Edge"], {"default": "Ninguno"}),
"update_yt_dlp": ("BOOLEAN", {"default": False}),
"quality": (["best", "1080p", "720p", "480p", "360p"], {"default": "best"}),
"format": (formats, {"default": "mp4"}),
}
}
RETURN_TYPES = ("*", "STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("video_path", "info", "title", "description", "thumbnail_url", "channel")
FUNCTION = "download_video"
CATEGORY = "video/download"
@classmethod
def IS_CHANGED(cls, url, cookies_text, cookies_file, browser_source, update_yt_dlp, quality, format):
import hashlib
state_string = f"{url}_{cookies_text}_{cookies_file}_{browser_source}_{quality}_{format}"
m = hashlib.sha256()
m.update(state_string.encode('utf-8'))
return m.digest().hex()
def get_format_string(self, quality, ext, is_audio):
print(f"\n{'='*60}")
print(f"🎯 [YT-DLP] EVALUADOR DE FORMATOS Y CÓDECS")
if is_audio:
f_str = "bestaudio/best"
print(f" -> 🎵 Modo: Solo Audio")
print(f" -> 🛠️ Regla aplicada: {f_str}")
print(f"{'='*60}\n")
return f_str
h_filter = "" if quality == "best" else f"[height<={quality.replace('p', '')}]"
# 🚀 FIX DEFINITIVO 5.0 (El fin de la saga TikTok):
# 1. TikTok etiqueta el H.264 como 'h264', no como 'avc'. Priorizamos h264.
# 2. Bloqueo explícito de 'bytevc1' (Contenedor HEVC problemático/mudo de TikTok).
# 3. Permitimos bestvideo+bestaudio (La fusión a mp4 se hace por 'copy' usando 0 RAM).
f_str = (
f"bestvideo{h_filter}[vcodec*=h264]+bestaudio/"
f"best{h_filter}[vcodec*=h264]/"
f"bestvideo{h_filter}[vcodec!=bytevc1]+bestaudio/"
f"best{h_filter}[vcodec!=bytevc1]/"
f"bestvideo{h_filter}+bestaudio/"
f"best{h_filter}/"
f"best"
)
print(f" -> 📺 Modo: Video + Audio")
print(f" -> 📏 Calidad objetivo: {quality} | Contenedor: {ext}")
print(f" -> 🛡️ Red de seguridad generada (Filtro Anti-Bytevc1):")
for i, fallback in enumerate(f_str.split('/'), 1):
print(f" {i}. {fallback}")
print(f"{'='*60}\n")
return f_str
def download_video(self, url, cookies_text, cookies_file, browser_source, update_yt_dlp, quality, format):
if update_yt_dlp:
print("🔄 ComfyUI-Ytdpl: Iniciando actualización forzada a NIGHTLY y motor EJS...")
try:
subprocess.check_call([
sys.executable, "-m", "pip", "install",
"--no-cache-dir", "-U", "--pre",
"yt-dlp[default]", "yt-dlp-ejs", "curl_cffi", "websockets", "--break-system-packages"
])
print("✅ ComfyUI-Ytdpl: Actualización completada.")
except subprocess.CalledProcessError as e:
raise Exception(f"❌ Error crítico al actualizar yt-dlp: {str(e)}\nRevisa tu conexión a internet.")
except Exception as e:
raise Exception(f"❌ Error inesperado al actualizar: {str(e)}")
if not url.strip():
raise Exception("❌ La URL está vacía.")
# 🚀 FIX: Bypass para Carruseles de Fotos de TikTok
# yt-dlp no reconoce las URLs con "/photo/". Si la cambiamos a "/video/",
# la API de TikTok entrega el mismo medio y yt-dlp lo extrae perfectamente.
url_lower = url.lower()
if "tiktok.com" in url_lower and "/photo/" in url_lower:
print("🔄 [TIKTOK BYPASS]: Detectada URL de galería de fotos. Adaptando enlace para yt-dlp...")
# Reemplazamos solo la primera aparición por seguridad
import re
url = re.sub(r'/photo/', '/video/', url, count=1, flags=re.IGNORECASE)
print(f"🔗 Nueva URL inyectada: {url}")
dest_path = self.output_dir
# === AÑADIDO 'mka' QUE FALTABA EN LA VALIDACIÓN ===
is_audio = format in ["mp3", "m4a", "wav", "flac", "ogg", "opus", "aac", "mka"]
# === LÓGICA DE COOKIES ===
cookie_path_to_use = None
if cookies_text and cookies_text.strip():
cookie_path_to_use = self.cookies_dir / "cookies_pegadas.txt"
try:
with open(cookie_path_to_use, 'w', encoding='utf-8') as f:
f.write(cookies_text)
print(f"🍪 Cookies creadas y guardadas permanentemente en: {cookie_path_to_use.name}")
except Exception as e:
raise Exception(f"❌ Error al guardar las cookies en input/cookies: {e}")
elif cookies_file != "Ninguno":
c_path = self.cookies_dir / cookies_file
if c_path.exists():
cookie_path_to_use = c_path
print(f"🍪 Usando archivo de cookies existente: {c_path.name}")
else:
print(f"⚠️ El archivo {cookies_file} no existe. Continuando sin cookies.")
auto_cookie_path = self.cookies_dir / "auto_cookies.txt"
for attempt in range(2):
try:
def build_cmd(q_val, get_filename=False):
f_str = self.get_format_string(q_val, format, is_audio)
cmd = [
sys.executable, "-m", "yt_dlp",
"-f", f_str,
"--restrict-filenames",
"-P", str(dest_path),
"--no-overwrites",
"--yes-playlist",
"--ignore-errors",
"--remote-components", "ejs:github"
]
if is_audio and not get_filename:
cmd.extend(["-x", "--audio-format", format])
if cookie_path_to_use:
cmd.extend(["--cookies", str(cookie_path_to_use)])
cmd.extend(["--extractor-args", "youtube:player_client=tv_downgraded,web;po_token=web+bgutil"])
else:
cmd.extend(["--extractor-args", "youtube:player_client=android,tv;po_token=web+bgutil"])
# === CAMBIO: USAR NOMBRES GENÉRICOS DE NAVEGADOR PARA CURL-CFFI ===
if browser_source != "Ninguno":
browser_map = {
"Chrome": "chrome",
"Firefox": "firefox",
"Safari": "safari",
"Edge": "edge"
}
target = browser_map.get(browser_source)
if target:
cmd.extend(["--impersonate", target])
if get_filename:
cmd.append("--get-filename")
if not is_audio:
cmd.extend(["--merge-output-format", format])
# Pedir a yt-dlp que genere un archivo con todos los metadatos
cmd.append("--write-info-json")
cmd.append(url)
return cmd
print(f"🔍 Calculando nombre de archivo para: {url}")
cmd_filename = build_cmd(quality, get_filename=True)
filename_res = subprocess.run(cmd_filename, capture_output=True, text=True, timeout=1800)
selected_quality = quality
if filename_res.returncode != 0 and quality != "best":
print(f"⚠️ No se pudo calcular nombre para '{quality}'. Probando con 'best'...")
selected_quality = "best"
cmd_filename = build_cmd("best", get_filename=True)
filename_res = subprocess.run(cmd_filename, capture_output=True, text=True, timeout=1800)
if filename_res.returncode != 0:
error_stderr = filename_res.stderr or ""
error_stderr_lower = error_stderr.lower()
if "permission denied" in error_stderr_lower:
raise Exception(f"🛑 Error de permisos con el archivo de cookies seleccionado ({cookies_file}). SOLUCIÓN: Abre ese archivo en texto plano, copia todo, pégalo en el cajón 'cookies_text' de ComfyUI y borra el archivo original problemático.")
raise Exception(f"YT_DLP_ERROR: {error_stderr}")
expected_filename = filename_res.stdout.strip().splitlines()[-1]
expected_path = Path(expected_filename)
print(f"🎯 Archivo esperado: {expected_path.name}")
print(f"📥 Iniciando descarga ({selected_quality})...")
cmd_dl = build_cmd(selected_quality, get_filename=False)
# 🚀 NUEVO LOG: Imprimir el comando exacto para debug
print(f"⚙️ [YT-DLP] Ejecutando comando final en el sistema:")
print(f" -> {' '.join(cmd_dl)}\n")
print("--- Registro de yt-dlp (ComfyUI) ---")
proc = subprocess.Popen(cmd_dl, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
output_lines = []
for line in proc.stdout:
sys.stdout.write(line)
sys.stdout.flush()
output_lines.append(line)
proc.wait()
output = "".join(output_lines)
print("------------------------------------\n")
if proc.returncode == 0:
final_path = None
if expected_path.exists():
final_path = expected_path
else:
print("⚠️ Archivo predicho no encontrado, buscando el más reciente...")
# Filtramos los .json para no seleccionar el archivo de metadatos por error
files = [f for f in dest_path.glob("*.*") if not f.name.endswith(".json")]
if not files: raise Exception("Archivo de video no encontrado tras descarga exitosa.")
final_path = max(files, key=lambda f: f.stat().st_mtime)
if is_audio:
actual_ext = final_path.suffix.lower().lstrip(".")
audio_formats = ["mp3", "m4a", "wav", "flac", "ogg", "opus", "aac", "mka"]
if actual_ext not in audio_formats:
raise Exception("❌ El vídeo se descargó correctamente pero no se encontró ninguna pista de audio para extraer. (Posible vídeo mudo de TikTok/YouTube).")
if not is_audio:
# 🔍 SISTEMA DE VERIFICACIÓN DE CÓDECS
try:
probe_cmd = [
"ffprobe", "-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=codec_name",
"-of", "default=noprint_wrappers=1:nokey=1",
str(final_path)
]
probe_res = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=15)
audio_codec = probe_res.stdout.strip()
if not audio_codec or "unable to obtain" in probe_res.stderr.lower():
print(f"\n⚠️ [AUDITORÍA DE AUDIO]: No se detectó un códec de audio válido en {final_path.name}.")
print("👉 Posibles causas:")
print(" 1. El video original NO tiene sonido (es mudo en la fuente).")
print(" 2. Faltan librerías de códecs. Para asegurarte, ejecuta en tu terminal WSL/Linux:")
print(" sudo apt update && sudo apt install ffmpeg libavcodec-extra -y\n")
else:
print(f"🎵 [AUDITORÍA DE AUDIO]: Códec detectado correctamente -> {audio_codec.upper()}")
except Exception as e:
print(f"⚠️ [AUDITORÍA DE AUDIO]: No se pudo ejecutar ffprobe: {e}")
# --- Leer metadatos del JSON ---
title, description, thumbnail_url, channel = "", "", "", ""
import json
json_path = final_path.with_suffix('.info.json')
if json_path.exists():
try:
with open(json_path, 'r', encoding='utf-8') as f:
meta = json.load(f)
title = meta.get("title", "")
description = meta.get("description", "")
thumbnail_url = meta.get("thumbnail", "")
channel = meta.get("uploader", "")
os.remove(json_path)
except Exception as e:
print(f"⚠️ Error leyendo metadatos JSON: {e}")
return (str(final_path), f"✅ Éxito: {final_path.name}", title, description, thumbnail_url, channel)
else:
raise Exception(f"YT_DLP_ERROR: {output}")
except Exception as e:
error_msg = str(e).lower()
if "yt_dlp_error:" in error_msg:
# Check for blocking keywords
if any(x in error_msg for x in ["captcha", "403", "forbidden", "verify", "sign in", "js challenge", "private"]):
if attempt == 0:
print("🛑 Bloqueo detectado. Lanzando Auto-Cookie...")
success = get_cookies_interactively(url, auto_cookie_path)
if success:
cookie_path_to_use = str(auto_cookie_path)
continue
else:
raise Exception("🛑 YouTube/TikTok bloqueó la descarga (Requiere Sesión/Captcha) y la interfaz gráfica falló. Pega tu texto de cookies actualizado en el nodo de forma manual.")
else:
raise Exception(f"🛑 Error final de descarga:\n{error_msg[-200:]}")
else:
raise Exception(f"🛑 Error en yt-dlp:\n{error_msg[-200:]}")
else:
raise e
NODE_CLASS_MAPPINGS = {"YTDLPVideoDownloader": YTDLPVideoDownloader}
NODE_DISPLAY_NAME_MAPPINGS = {"YTDLPVideoDownloader": "YT-DLP Downloader (Auto-Quality) 📥"}