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 2. Presiona F12 (o haz clic derecho → Inspeccionar). Luego abre la pestaña Network y recarga la página (Reload).
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.
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()