Video Tutorial

Documentación

🎵 Guía de Instalación - Spotify FLAC Downloader

🪟 Windows

Paso 1. Instalar Python

Ve a:

https://www.python.org/downloads/

Descarga la última versión estable.

Durante la instalación:

  • Marca Add Python to PATH.
  • Haz clic en Install Now.

Paso 2. Instalación Automática de Dependencias (Recomendado)

Para evitar instalar las cosas manualmente, puedes usar el instalador automático que viene incluido en la descarga.

Simplemente extrae el archivo .zip que descargaste, busca el archivo instalador.bat y haz doble clic sobre él para ejecutarlo.

(Si quieres ver qué hace el instalador por debajo, aquí tienes su código:)

@echo off
title Instalador de Dependencias
color 0A

echo ========================================
echo    INSTALADOR DE DEPENDENCIAS
echo ========================================
echo.

:: Comprobar winget
where winget >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Winget no esta instalado.
    echo Actualiza Windows o instala App Installer desde Microsoft Store.
    pause
    exit /b 1
)

:: Comprobar Python
where py >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Python no esta instalado.
    echo Descargalo desde https://www.python.org/downloads/
    pause
    exit /b 1
)

echo [1/4] Instalando FFmpeg...
winget install --id Gyan.FFmpeg -e --accept-package-agreements --accept-source-agreements

if errorlevel 1 (
    echo.
    echo [ERROR] Fallo la instalacion de FFmpeg.
    pause
    exit /b 1
)

echo.
echo [2/4] Instalando FLAC...
winget install --id Xiph.FLAC -e --accept-package-agreements --accept-source-agreements

if errorlevel 1 (
    echo.
    echo [ERROR] Fallo la instalacion de FLAC.
    pause
    exit /b 1
)

echo.
echo [3/4] Instalando SpotiFLAC y dependencias...
py -m pip install --upgrade pip
py -m pip install -U requests SpotiFLAC

if errorlevel 1 (
    echo.
    echo [ERROR] Fallo la instalacion de SpotiFLAC.
    pause
    exit /b 1
)

echo.
echo [4/4] Instalando nodriver...
py -m pip install -U nodriver

if errorlevel 1 (
    echo.
    echo [ERROR] Fallo la instalacion de nodriver.
    pause
    exit /b 1
)

echo.
echo Parcheando nodriver para evitar bugs de codificacion...
py -c "import os, site; paths = site.getsitepackages() + [site.getusersitepackages()]; [open(p, 'wb').write(open(p, 'rb').read().replace(b'\xb1', b'')) for paths_ in paths for p in [os.path.join(paths_, 'nodriver', 'cdp', 'network.py')] if os.path.exists(p) and b'\xb1' in open(p, 'rb').read()]" 2>nul

echo.
echo ========================================
echo      INSTALACION COMPLETADA
echo ========================================
echo FFmpeg, FLAC, SpotiFLAC y nodriver fueron instalados.
echo.
pause

Paso 3. Instalación Manual (Alternativa)

Si prefieres instalar todo manualmente desde PowerShell como Administrador, ejecuta:

winget install --id Gyan.FFmpeg -e --accept-package-agreements --accept-source-agreements
winget install --id Xiph.FLAC -e --accept-package-agreements --accept-source-agreements
py -m pip install --upgrade pip
py -m pip install -U requests SpotiFLAC
py -m pip install -U nodriver

luego en cmd ponemos:

py -c "import os, site; paths = site.getsitepackages() + [site.getusersitepackages()]; [open(p, 'wb').write(open(p, 'rb').read().replace(b'\xb1', b'')) for paths_ in paths for p in [os.path.join(paths_, 'nodriver', 'cdp', 'network.py')] if os.path.exists(p) and b'\xb1' in open(p, 'rb').read()]" 2>nul

Paso 4. Ejecutar el programa

Pon el programa en una carpeta vacía.

Método 1: Abrir con Python

  • Haz clic derecho sobre spotidown.py.
  • Selecciona Abrir con → Python.

Método 2: PowerShell

Abre una terminal dentro de la carpeta y ejecuta:

py spotidown.py

🍎 macOS

Paso 1. Instalar Python

O instala mediante Homebrew:

brew install python

Paso 2. Instalar FFmpeg y FLAC

brew install ffmpeg flac

Paso 3. Instalar SpotiFLAC y nodriver

python3 -m pip install --upgrade pip
python3 -m pip install -U SpotiFLAC nodriver

Paso 4. Ejecutar

python3 spotidown.py

🐧 Linux (Ubuntu/Debian)

Paso 1. Instalar Python

sudo apt update
sudo apt install python3 python3-pip python3-tk -y

Paso 2. Instalar FFmpeg y FLAC

sudo apt install ffmpeg flac -y

Paso 3. Instalar SpotiFLAC y nodriver

python3 -m pip install --upgrade pip
python3 -m pip install -U SpotiFLAC nodriver

Paso 4. Ejecutar

python3 spotidown.py

✅ Verificar que todo está correctamente instalado

py --version
ffmpeg -version
flac --version
py -m pip show SpotiFLAC

Librerías Necesarias

winget install --id Gyan.FFmpeg -e --accept-package-agreements --accept-source-agreements
winget install --id Xiph.FLAC -e --accept-package-agreements --accept-source-agreements
py -m pip install -U SpotiFLAC nodriver

Tutorial de Verificación de Servidores

Paso 1. Copia el enlace de verificación y pégalo en el navegador para completar la verificación antibot.

Paso 1 Paso 1

Paso 2. Presiona F12 (o haz clic derecho → Inspeccionar). Luego abre la pestaña Network y recarga la página (Reload).

Paso 2 Paso 2 Paso 2

Paso 3. En la lista de solicitudes aparecerá una llamada llamada session granted. Haz clic sobre ella y copia el código que aparece después del signo =, normalmente después de grant= o grant?state=. Luego pégalo en el campo de verificación requerida del programa.

Paso 3 Paso 3

Nota: Este procedimiento no es necesario para todos los servidores, solo para algunos. Además, procura hacerlo rápidamente, ya que el código de autorización expira después de unos minutos.

Importante

En algunos casos el servidor puede solicitar una segunda verificación. No te preocupes, el programa detectará automáticamente el enlace y mostrará el botón "Abrir enlace de verificación".

También puedes copiar manualmente el enlace (similar a https://api.zarz.moe/v2/challenge?id=...) y abrirlo en tu navegador. Completa el CAPTCHA antibot; cuando termine, el navegador se cerrará automáticamente. Después vuelve a abrir el mismo enlace, obtendrás el código de autorización y solo tendrás que pegarlo en el programa para continuar.

Código Fuente Principal (spotidown.py)

import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext, filedialog, simpledialog
import threading
import asyncio
import logging
import sys
import re
import webbrowser

# ─── Importar SpotiFLAC ────────────────────────────────────────────────────────
SPOTIFLAC_LISTO = False
SpotiFLAC = None
run_health_check = None
print_health_report = None
get_working_providers = None
ERROR_IMPORT = ""

try:
    from SpotiFLAC import SpotiFLAC as _SF
    from SpotiFLAC.core.health_check import (
        run_health_check as _rhc,
        print_health_report as _phr,
        get_working_providers as _gwp,
    )
    SpotiFLAC = _SF
    run_health_check = _rhc
    print_health_report = _phr
    get_working_providers = _gwp
    SPOTIFLAC_LISTO = True
    print("SpotiFLAC importado correctamente")
except (ImportError, ModuleNotFoundError) as e:
    ERROR_IMPORT = str(e)
    print(f"Error al importar SpotiFLAC: {e}")

ALL_SERVICES = ["qobuz", "tidal", "deezer", "amazon", "soundcloud", "youtube", "apple", "pandora", "joox", "netease", "migu", "kuwo"]
# Nota: tidal y deezer comparten el proxy api.zarz.moe — si ese proxy está caído, ambos fallan.


# ─── Redirigir stdout/stderr y logging al widget de la UI ─────────────────────
_VERIFY_TRIGGER = re.compile(r'open this link in the browser', re.IGNORECASE)
_URL_RE = re.compile(r'https?://\S+')

class UIStream:
    encoding = "utf-8"
    errors = "replace"

    def __init__(self, callback, on_verify_url=None):
        self._cb = callback
        self._buf = ""
        self._next_is_url = False
        self._on_verify_url = on_verify_url

    def write(self, text):
        if text:
            self._buf += text
            if "\n" in self._buf:
                lines = self._buf.split("\n")
                for line in lines[:-1]:
                    stripped = line.strip()
                    if stripped:
                        self._cb(line)
                    # Detectar disparador del link de verificación
                    if _VERIFY_TRIGGER.search(line):
                        self._next_is_url = True
                    elif self._next_is_url and stripped:
                        m = _URL_RE.search(stripped)
                        if m and self._on_verify_url:
                            self._on_verify_url(m.group(0))
                        self._next_is_url = False
                self._buf = lines[-1]

    def flush(self):
        if self._buf.strip():
            self._cb(self._buf)
            self._buf = ""


class _VerificationDialog(tk.Toplevel):
    def __init__(self, parent, url):
        super().__init__(parent)
        self.title("Verificación requerida")
        self.geometry("550x300")
        self.resizable(False, False)
        self.result = None
        self.url = url

        # Hacerlo modal
        self.transient(parent)
        self.grab_set()

        padding = {"padx": 15, "pady": 10}

        lbl_info = ttk.Label(
            self, 
            text="SpotiFLAC necesita verificación para continuar.\n"
                 "Copia el enlace de abajo o ábrelo directamente en tu navegador.",
            font=("Arial", 10)
        )
        lbl_info.pack(**padding, fill="x")

        # URL Frame
        url_frame = ttk.Frame(self)
        url_frame.pack(padx=15, fill="x")
        
        self.url_var = tk.StringVar(value=url if url else "URL no detectada en el log")
        url_entry = ttk.Entry(url_frame, textvariable=self.url_var, state="readonly")
        url_entry.pack(side="left", fill="x", expand=True)

        if url:
            btn_open = ttk.Button(url_frame, text="🌐 Abrir en navegador", command=self._abrir_url)
            btn_open.pack(side="left", padx=(5, 0))

        # Instrucciones
        lbl_inst = ttk.Label(
            self,
            text="\n1. Completa el CAPTCHA en el navegador.\n"
                 "2. Abre DevTools (F12) → Pestaña 'Red' (Network).\n"
                 "3. Busca la petición 'verify' → Vista previa (Preview).\n"
                 "4. Copia el valor del campo 'grant' y pégalo aquí abajo:",
            font=("Arial", 9)
        )
        lbl_inst.pack(padx=15, pady=(5, 10), fill="x")

        # Grant Frame
        grant_frame = ttk.Frame(self)
        grant_frame.pack(padx=15, fill="x")
        
        ttk.Label(grant_frame, text="Grant Code:").pack(side="left")
        self.grant_var = tk.StringVar()
        grant_entry = ttk.Entry(grant_frame, textvariable=self.grant_var)
        grant_entry.pack(side="left", fill="x", expand=True, padx=5)
        grant_entry.focus()

        # Botón Aceptar
        btn_ok = ttk.Button(self, text="Aceptar", command=self._aceptar)
        btn_ok.pack(pady=15)

        # Vincular Enter
        self.bind("", lambda e: self._aceptar())

    def _abrir_url(self):
        if self.url:
            webbrowser.open(self.url)

    def _aceptar(self):
        self.result = self.grant_var.get().strip()
        self.destroy()


class UIStdinStream:
    """Reemplaza sys.stdin durante la descarga.
    Muestra un diálogo personalizado para ingresar el grant code.
    """
    encoding = "utf-8"

    def __init__(self, app):
        self._app = app

    def readline(self):
        result = [None]
        event = threading.Event()
        url = getattr(self._app, '_last_verify_url', None)

        def _ask():
            dialog = _VerificationDialog(self._app.root, url)
            self._app.root.wait_window(dialog)
            result[0] = dialog.result or ""
            event.set()

        self._app.root.after(0, _ask)
        event.wait()
        return result[0] + "\n"

    def read(self, n=-1):
        return self.readline()

    def fileno(self):
        raise OSError("UIStdinStream no tiene file descriptor")

    def isatty(self):
        return False



class UILogHandler(logging.Handler):
    def __init__(self, callback, on_verify_url=None):
        super().__init__()
        self._cb = callback
        self._on_verify_url = on_verify_url

    def emit(self, record):
        # Solo mostrar WARNING+ para no llenar de ruido HTTP debug
        if record.levelno < logging.WARNING:
            return
        msg = self.format(record)
        level = record.levelname
        prefix = {"WARNING": "⚠️", "ERROR": "❌", "CRITICAL": "🔥"}.get(level, "ℹ️")
        self._cb(f"{prefix} {msg}")

        # Detectar el link de verificación en los warnings
        if "challenge?id=" in msg and self._on_verify_url:
            m = _URL_RE.search(msg)
            if m:
                self._on_verify_url(m.group(0))


# ──────────────────────────────────────────────────────────────────────────────
class SpotifyFLACDownloader:
    def __init__(self, root):
        self.root = root
        self.root.title("🎵 Spotify FLAC Downloader")
        self.root.geometry("940x800")
        self.root.resizable(True, True)

        self.descargando = False
        self._orig_stdout = sys.stdout
        self._orig_stderr = sys.stderr
        self._log_handler = None

        # Estado de health check por servicio: None=desconocido, True=OK, False=fallo
        self.health_status: dict[str, bool | None] = {s: None for s in ALL_SERVICES}

        # Loop de asyncio persistente para evitar "bound to a different event loop"
        self.loop = asyncio.new_event_loop()
        self.loop_thread = threading.Thread(target=self._run_async_loop, daemon=True)
        self.loop_thread.start()

        self.setup_ui()

        if SPOTIFLAC_LISTO:
            self.log("✅ SpotiFLAC cargado correctamente")
            self.log("💡 Haz clic en 'Verificar Servidores' para saber cuáles funcionan antes de descargar.\n")
        else:
            self.log("❌ No se pudo importar SpotiFLAC")
            self.log(f"   Detalle: {ERROR_IMPORT}")
            self.log("   Instala con: py -m pip install -U SpotiFLAC\n")

    # ── Construcción de la UI ─────────────────────────────────────────────────

    def setup_ui(self):
        main = ttk.Frame(self.root, padding=15)
        main.pack(fill="both", expand=True)

        # Título
        ttk.Label(main, text="🎵 Spotify FLAC Downloader", font=("Arial", 16, "bold")).pack(pady=(0, 10))

        # URL
        url_frame = ttk.LabelFrame(
            main,
            text="URL · Spotify / Tidal / Apple Music / SoundCloud / YouTube / Pandora",
            padding=10,
        )
        url_frame.pack(fill="x", pady=5)
        ttk.Label(url_frame, text="URL:").pack(anchor="w")
        self.url_var = tk.StringVar()
        ttk.Entry(url_frame, textvariable=self.url_var, font=("Arial", 10)).pack(fill="x", pady=5)

        # ── Panel de Servidores con Health Check ──────────────────────────────
        srv_outer = ttk.LabelFrame(main, text="🔍 Estado de Servidores", padding=10)
        srv_outer.pack(fill="x", pady=5)

        # Fila superior: botón verificar + selector de servicio
        top_row = ttk.Frame(srv_outer)
        top_row.pack(fill="x", pady=(0, 8))

        self.check_btn = ttk.Button(
            top_row,
            text="🔍 Verificar Servidores",
            command=self.iniciar_health_check,
            width=22,
        )
        self.check_btn.pack(side="left", padx=(0, 15))

        ttk.Label(top_row, text="Servicio a usar:").pack(side="left")
        self.servicio_var = tk.StringVar(value="qobuz")
        self.servicio_combo = ttk.Combobox(
            top_row,
            textvariable=self.servicio_var,
            values=ALL_SERVICES,
            state="readonly",
            width=16,
        )
        self.servicio_combo.pack(side="left", padx=8)

        # Nota sobre proxy compartido
        ttk.Label(
            top_row,
            text="⚠️ Tidal y Deezer comparten proxy (api.zarz.moe)",
            font=("Arial", 8),
            foreground="#cc7700",
        ).pack(side="left", padx=6)

        self.health_status_label = ttk.Label(
            top_row, text="Estado: sin verificar", font=("Arial", 9, "italic"), foreground="gray"
        )
        self.health_status_label.pack(side="left", padx=10)

        # Grid de indicadores de estado por servicio
        indicators_frame = ttk.Frame(srv_outer)
        indicators_frame.pack(fill="x")

        self.service_labels: dict[str, ttk.Label] = {}
        for i, svc in enumerate(ALL_SERVICES):
            cell = ttk.Frame(indicators_frame)
            cell.grid(row=0, column=i, padx=6, pady=2)
            ttk.Label(cell, text=svc, font=("Arial", 8, "bold")).pack()
            lbl = ttk.Label(cell, text="⬜", font=("Arial", 14))
            lbl.pack()
            self.service_labels[svc] = lbl

        # ── Opciones de Descarga ──────────────────────────────────────────────
        opt = ttk.LabelFrame(main, text="Opciones de Descarga", padding=10)
        opt.pack(fill="x", pady=8)

        # Calidad
        ttk.Label(opt, text="Calidad:").grid(row=0, column=0, sticky="w", padx=5, pady=4)
        self.calidad_var = tk.StringVar(value="LOSSLESS")
        ttk.Combobox(
            opt,
            textvariable=self.calidad_var,
            values=["DOLBY_ATMOS", "HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW", "alac", "atmos", "ac3", "aac", "aac-legacy", "mp3_192", "aac_64", "aac_32"],
            state="readonly",
            width=20,
        ).grid(row=0, column=1, padx=5, pady=4)

        # Formato de archivo
        ttk.Label(opt, text="Formato de archivo:").grid(row=0, column=2, sticky="w", padx=10, pady=4)
        self.formato_var = tk.StringVar(value="{title} - {artist}")
        ttk.Combobox(
            opt,
            textvariable=self.formato_var,
            values=[
                "{title} - {artist}",
                "{track}. {title} - {artist}",
                "{year} - {album}/{track}. {title}",
                "{artist} - {title}",
                "{album}/{track}. {title}",
            ],
            width=28,
        ).grid(row=0, column=3, padx=5, pady=4)

        # Subcarpetas
        self.subfolders_album_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt, text="Subcarpetas por álbum", variable=self.subfolders_album_var).grid(
            row=1, column=0, columnspan=2, sticky="w", padx=5, pady=3
        )
        self.subfolders_artist_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt, text="Subcarpetas por artista", variable=self.subfolders_artist_var).grid(
            row=1, column=2, columnspan=2, sticky="w", padx=10, pady=3
        )

        # Opciones de artista y numeración
        self.first_artist_only_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt, text="Solo primer artista en tags/nombre", variable=self.first_artist_only_var).grid(
            row=2, column=0, columnspan=2, sticky="w", padx=5, pady=3
        )
        self.include_featuring_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt, text="Incluir 'featuring' (discografía artista)", variable=self.include_featuring_var).grid(
            row=2, column=2, columnspan=2, sticky="w", padx=10, pady=3
        )
        self.use_album_track_numbers_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(opt, text="Numeración original del álbum", variable=self.use_album_track_numbers_var).grid(
            row=3, column=0, columnspan=2, sticky="w", padx=5, pady=3
        )

        # Metadatos y letras
        self.metadata_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(
            opt, text="Enriquecer metadatos (portada HD, BPM, etc.)", variable=self.metadata_var
        ).grid(row=4, column=0, columnspan=2, sticky="w", padx=5, pady=3)

        self.lyrics_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opt, text="Incluir letra sincronizada (LRC)", variable=self.lyrics_var).grid(
            row=4, column=2, columnspan=2, sticky="w", padx=10, pady=3
        )

        # Intentos y timeout
        ttk.Label(opt, text="Intentos por fallo:").grid(row=5, column=0, sticky="w", padx=5, pady=4)
        self.retries_var = tk.StringVar(value="2")
        ttk.Spinbox(opt, from_=0, to=5, textvariable=self.retries_var, width=8).grid(
            row=5, column=1, sticky="w", padx=5, pady=4
        )

        ttk.Label(opt, text="Timeout por pista (seg, 0=sin límite):").grid(row=5, column=2, sticky="w", padx=10, pady=4)
        self.timeout_var = tk.StringVar(value="0")
        ttk.Spinbox(opt, from_=0, to=600, increment=30, textvariable=self.timeout_var, width=8).grid(
            row=5, column=3, sticky="w", padx=5, pady=4
        )

        # Qobuz API local (opcional)
        ttk.Label(opt, text="Qobuz API local (opcional):").grid(row=6, column=0, sticky="w", padx=5, pady=4)
        self.qobuz_api_var = tk.StringVar(value="")
        ttk.Entry(opt, textvariable=self.qobuz_api_var, width=35).grid(
            row=6, column=1, columnspan=3, sticky="w", padx=5, pady=4
        )
        ttk.Label(
            opt,
            text="(URL de tu instancia Qobuz local, ej: https://localhost:8000  — deja vacío para uso anónimo)",
            font=("Arial", 8),
            foreground="gray",
        ).grid(row=7, column=0, columnspan=4, sticky="w", padx=5, pady=0)

        # Tidal API personalizada (opcional)
        ttk.Label(opt, text="Tidal API personalizada (opcional):").grid(row=8, column=0, sticky="w", padx=5, pady=4)
        self.tidal_api_var = tk.StringVar(value="")
        ttk.Entry(opt, textvariable=self.tidal_api_var, width=35).grid(
            row=8, column=1, columnspan=3, sticky="w", padx=5, pady=4
        )
        ttk.Label(
            opt,
            text="(URL de tu instancia hifi-api propia, ej: https://tu-server.com  — deja vacío para usar mirrors públicos)",
            font=("Arial", 8),
            foreground="gray",
        ).grid(row=9, column=0, columnspan=4, sticky="w", padx=5, pady=0)

        # Acción post-descarga
        ttk.Label(opt, text="Acción post-descarga:").grid(row=10, column=0, sticky="w", padx=5, pady=4)
        self.post_action_var = tk.StringVar(value="none")
        ttk.Combobox(
            opt,
            textvariable=self.post_action_var,
            values=["none", "open_folder", "notify", "command"],
            state="readonly",
            width=16,
        ).grid(row=10, column=1, padx=5, pady=4)

        ttk.Label(opt, text="Comando (si acción=command):").grid(row=10, column=2, sticky="w", padx=10, pady=4)
        self.post_command_var = tk.StringVar(value="")
        ttk.Entry(opt, textvariable=self.post_command_var, width=28).grid(
            row=10, column=3, padx=5, pady=4
        )

        # ── Botones de acción ─────────────────────────────────────────────────
        btn_frame = ttk.Frame(main)
        btn_frame.pack(pady=8)

        self.descargar_btn = ttk.Button(
            btn_frame, text="📥 DESCARGAR FLAC", command=self.iniciar_descarga, width=22
        )
        self.descargar_btn.pack(side="left", padx=5)

        self.cancelar_btn = ttk.Button(
            btn_frame, text="⏹️ Cancelar", command=self.cancelar_descarga, width=14, state="disabled"
        )
        self.cancelar_btn.pack(side="left", padx=5)

        ttk.Button(btn_frame, text="🗑️ Limpiar Log", command=self.limpiar_log, width=14).pack(
            side="left", padx=5
        )

        # ── URLs de ejemplo ───────────────────────────────────────────────────
        ej = ttk.LabelFrame(main, text="📋 URLs de Ejemplo", padding=8)
        ej.pack(fill="x", pady=4)
        ttk.Label(ej, font=("Consolas", 9), justify="left", text=(
            "Spotify Track:    https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT\n"
            "Spotify Album:    https://open.spotify.com/album/2noRn2Aes5aoNVsU6iWThc\n"
            "Spotify Playlist: https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M\n"
            "Tidal Album:      https://listen.tidal.com/album/364272512"
        )).pack(anchor="w")

        # ── Log ───────────────────────────────────────────────────────────────
        log_frame = ttk.LabelFrame(main, text="📝 Log", padding=5)
        log_frame.pack(fill="both", expand=True, pady=6)
        self.log_text = scrolledtext.ScrolledText(log_frame, height=12, font=("Consolas", 9))
        self.log_text.pack(fill="both", expand=True)

        # Estado
        self.estado_var = tk.StringVar(value="✅ Listo")
        ttk.Label(main, textvariable=self.estado_var, font=("Arial", 9, "italic")).pack(
            anchor="w", pady=(4, 0)
        )

    # ── Helpers y Asyncio ─────────────────────────────────────────────────────

    def _run_async_loop(self):
        asyncio.set_event_loop(self.loop)
        self.loop.run_forever()

    def log(self, mensaje):
        """Thread-safe: inserta texto en el log."""
        def _ins():
            self.log_text.insert(tk.END, str(mensaje) + "\n")
            self.log_text.see(tk.END)
        self.root.after(0, _ins)

    def _activar_captura(self):
        self._last_verify_url = None

        def _on_verify_url(url):
            self._last_verify_url = url

        stream = UIStream(self.log, on_verify_url=_on_verify_url)
        sys.stdout = stream
        sys.stderr = stream
        self._orig_stdin = sys.stdin
        sys.stdin = UIStdinStream(self)
        handler = UILogHandler(self.log, on_verify_url=_on_verify_url)
        fmt = logging.Formatter("%(name)s - %(message)s")
        handler.setFormatter(fmt)
        self._log_handler = handler
        for name in ("", "SpotiFLAC"):
            lg = logging.getLogger(name)
            lg.setLevel(logging.WARNING)
            lg.addHandler(handler)

    def _desactivar_captura(self):
        sys.stdout = self._orig_stdout
        sys.stderr = self._orig_stderr
        if hasattr(self, "_orig_stdin"):
            sys.stdin = self._orig_stdin
        if self._log_handler:
            for name in ("", "SpotiFLAC"):
                logging.getLogger(name).removeHandler(self._log_handler)
            self._log_handler = None

    # ── Health Check ──────────────────────────────────────────────────────────

    def iniciar_health_check(self):
        if not SPOTIFLAC_LISTO:
            messagebox.showerror("Error", "SpotiFLAC no está disponible.")
            return

        self.check_btn.config(state="disabled")
        self.health_status_label.config(text="⏳ Verificando...", foreground="orange")

        # Resetear indicadores
        for svc, lbl in self.service_labels.items():
            lbl.config(text="⏳")
            self.health_status[svc] = None

        t = threading.Thread(target=self._run_health_check, daemon=True)
        t.start()

    def _run_health_check(self):
        try:
            # run_health_check es async — se ejecuta en el event loop persistente
            future = asyncio.run_coroutine_threadsafe(run_health_check(ALL_SERVICES), self.loop)
            results = future.result()
            working = get_working_providers(results)

            # Construir mapa de estado
            status_map: dict[str, bool] = {}
            for r in results:
                svc = getattr(r, "service", None) or getattr(r, "provider", None) or ""
                ok = getattr(r, "ok", None)
                if ok is None:
                    ok = getattr(r, "available", False)
                status_map[svc.lower()] = bool(ok)

            # Actualizar UI en hilo principal
            self.root.after(0, self._actualizar_health_ui, status_map, working)
        except Exception as e:
            self.root.after(0, self._health_check_error, str(e))

    def _actualizar_health_ui(self, status_map: dict[str, bool], working: list[str]):
        for svc, lbl in self.service_labels.items():
            ok = status_map.get(svc)
            if ok is True:
                lbl.config(text="✅", foreground="green")
            elif ok is False:
                lbl.config(text="❌", foreground="red")
            else:
                lbl.config(text="⬜", foreground="gray")
            self.health_status[svc] = ok

        self.check_btn.config(state="normal")

        if working:
            self.health_status_label.config(
                text=f"✅ Disponibles: {', '.join(working)}", foreground="green"
            )
            # Auto-seleccionar el primero disponible si el actual no funciona
            current = self.servicio_var.get()
            if current not in working and working:
                self.servicio_var.set(working[0])
                self.log(f"💡 Auto-seleccionado '{working[0]}' (el servicio anterior no está disponible)")

            self.log(f"\n✅ Servidores disponibles: {', '.join(working)}")
            not_working = [s for s in ALL_SERVICES if s not in working]
            if not_working:
                self.log(f"❌ Sin servicio: {', '.join(not_working)}\n")
        else:
            self.health_status_label.config(text="❌ Ningún servicio disponible", foreground="red")
            self.log("❌ Ningún servicio disponible en este momento.\n")

    def _health_check_error(self, msg: str):
        self.check_btn.config(state="normal")
        self.health_status_label.config(text="⚠️ Error en verificación", foreground="orange")
        self.log(f"⚠️ Error al verificar servidores: {msg}")

    # ── Descarga ──────────────────────────────────────────────────────────────

    def iniciar_descarga(self):
        if not SPOTIFLAC_LISTO:
            messagebox.showerror(
                "Error",
                "SpotiFLAC no está disponible.\nInstálalo con: py -m pip install -U SpotiFLAC",
            )
            return

        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("Advertencia", "Introduce una URL válida")
            return

        # Limpiar parámetros (preservar ?i= de Apple Music)
        if "?" in url and "?i=" not in url:
            url = url.split("?")[0]

        # Advertir si el servicio seleccionado falló en el health check
        svc = self.servicio_var.get()
        if self.health_status.get(svc) is False:
            resp = messagebox.askyesno(
                "Servicio no disponible",
                f"'{svc}' falló en la última verificación.\n¿Continuar de todas formas?",
            )
            if not resp:
                return

        output_dir = filedialog.askdirectory(title="Selecciona carpeta para guardar la música")
        if not output_dir:
            return

        self.log_text.delete(1.0, tk.END)
        self.descargando = True
        self.descargar_btn.config(state="disabled")
        self.cancelar_btn.config(state="normal")
        self.estado_var.set("🔄 Descargando...")

        self.hilo = threading.Thread(
            target=self.descargar_musica, args=(url, output_dir), daemon=True
        )
        self.hilo.start()
        self.monitorear()

    def descargar_musica(self, url, output_dir):
        self._activar_captura()
        exito = False
        error_msg = ""
        try:
            svc = self.servicio_var.get()
            self.log(f"\n🚀 Iniciando descarga...")
            self.log(f"📌 URL: {url}")
            self.log(f"📁 Destino: {output_dir}")
            self.log(f"🔧 Servicio: {svc}")
            self.log(f"🎵 Calidad: {self.calidad_var.get()}")
            self.log(f"📝 Formato: {self.formato_var.get()}")
            self.log("=" * 55)

            # Parámetros opcionales
            qobuz_api = self.qobuz_api_var.get().strip() or None
            timeout_raw = int(self.timeout_var.get() or "0")
            timeout_s = timeout_raw if timeout_raw > 0 else None
            post_action = self.post_action_var.get()
            post_cmd = self.post_command_var.get().strip() or ""

            self.log("⏳ Conectando...\n")

            # Parámetros de APIs personalizadas
            tidal_api = self.tidal_api_var.get().strip() or None

            # ── API de SpotiFLAC ──────────────────────────────────────────────
            SpotiFLAC(
                url=url,
                output_dir=output_dir,
                services=[svc],
                quality=self.calidad_var.get(),
                filename_format=self.formato_var.get(),
                use_album_subfolders=self.subfolders_album_var.get(),
                use_artist_subfolders=self.subfolders_artist_var.get(),
                first_artist_only=self.first_artist_only_var.get(),
                include_featuring=self.include_featuring_var.get(),
                use_album_track_numbers=self.use_album_track_numbers_var.get(),
                track_max_retries=int(self.retries_var.get()),
                enrich_metadata=self.metadata_var.get(),
                embed_lyrics=self.lyrics_var.get(),
                qobuz_local_api_url=qobuz_api,
                tidal_custom_api=tidal_api,
                timeout_s=timeout_s,
                post_download_action=post_action,
                post_download_command=post_cmd,
            )

            exito = True

        except Exception as e:
            import traceback
            error_msg = f"{type(e).__name__}: {e}"
            self.log(f"\n❌ ERROR: {error_msg}")
            for line in traceback.format_exc().splitlines():
                self.log(f"   {line}")
        finally:
            self._desactivar_captura()

        if exito:
            self.root.after(0, self.descarga_exitosa, output_dir)
        else:
            self.root.after(0, self.descarga_error, error_msg)

    # ── Callbacks de resultado ────────────────────────────────────────────────

    def descarga_exitosa(self, output_dir):
        self.descargando = False
        self.descargar_btn.config(state="normal")
        self.cancelar_btn.config(state="disabled")
        self.estado_var.set("✅ Completado")
        self.log("=" * 55)
        self.log("🎉 ¡DESCARGA COMPLETADA!")
        self.log(f"📁 Ubicación: {output_dir}")
        messagebox.showinfo("Éxito", f"¡Música descargada!\nUbicación: {output_dir}")

    def descarga_error(self, error_msg):
        self.descargando = False
        self.descargar_btn.config(state="normal")
        self.cancelar_btn.config(state="disabled")
        self.estado_var.set("❌ Error")
        messagebox.showerror("Error en la descarga", error_msg)

    def cancelar_descarga(self):
        self.descargando = False
        self.estado_var.set("⏸️ Cancelado")
        self.log("\n⚠️ Cancelado por el usuario")
        self.cancelar_btn.config(state="disabled")
        self.descargar_btn.config(state="normal")

    def limpiar_log(self):
        self.log_text.delete(1.0, tk.END)

    def monitorear(self):
        if hasattr(self, "hilo") and self.hilo.is_alive():
            self.root.after(500, self.monitorear)


if __name__ == "__main__":
    root = tk.Tk()
    app = SpotifyFLACDownloader(root)
    root.mainloop()