Video Tutorial

Documentación

Guía Técnica: Automatización de Biblioteca Musical con Python

La gestión de bibliotecas digitales en alta fidelidad requiere herramientas que garanticen la integridad del audio sin pérdida de datos. Este script de Python permite a los entusiastas del sonido automatizar el proceso para descargar música spotify flac, extrayendo pistas en formato Lossless (16-bit o 24-bit) directamente desde servidores de streaming de alta calidad. Al integrar metadatos completos y soporte para portadas en alta resolución, esta solución técnica elimina la necesidad de conversiones intermedias, preservando la fidelidad original del archivo fuente.

A diferencia de los conversores convencionales, este motor utiliza una implementación nativa del protocolo de comunicación cifrada para asegurar que los archivos obtenidos sean completos y no fragmentos de previsualización. Si buscas optimizar tu colección personal, aprender a descargar música spotify flac mediante esta arquitectura de código abierto te ofrece un control total sobre tus activos multimedia, permitiendo una gestión de biblioteca local profesional y eficiente.

Guía de Instalación: Cómo descargar música spotify flac

Para garantizar un funcionamiento óptimo, el script requiere un entorno de ejecución configurado correctamente. A continuación, detallamos los pasos según tu sistema operativo.

🪟 Windows

En entornos Windows, el despliegue se simplifica mediante un script de automatización que gestiona el entorno virtual y las dependencias necesarias. Asegúrate de tener instalada la versión más reciente de Python desde python.org, marcando la opción "Add Python to PATH".

  1. Descarga el repositorio y extrae el contenido en una carpeta dedicada.
  2. Ejecuta el archivo instalador.bat (haz clic derecho -> Ejecutar como administrador si es necesario).
@echo off
setlocal
cd /d "%~dp0"
echo [*] Verificando dependencias y configurando entorno...
py -m pip install requests pycryptodome mutagen customtkinter pillow
echo [*] Iniciando el descargador de alta fidelidad...
start "" py "%~dp0spotidown.py"
pause

🍎 macOS

Para usuarios de macOS, recomendamos utilizar Homebrew para gestionar las dependencias de la interfaz gráfica (Tkinter) y las librerías de Python. Ejecuta los siguientes comandos en tu terminal:

brew install python python-tk
python3 -m pip install requests pycryptodome mutagen customtkinter pillow
# Ejecución del script
python3 spotidown.py

🐧 Linux (Ubuntu/Debian)

En sistemas basados en Debian, es necesario preparar el entorno de compilación de Python para asegurar que la interfaz gráfica y las librerías de cifrado funcionen correctamente:

sudo apt update
sudo apt install python3 python3-pip python3-tk -y
python3 -m pip install requests pycryptodome mutagen customtkinter pillow
python3 spotidown.py

🛠️ Solución de Errores Comunes (Troubleshooting)

Si experimentas dificultades al intentar descargar música spotify flac, consulta la siguiente guía de resolución de problemas técnicos:

  • ModuleNotFoundError: Indica que una dependencia no se instaló correctamente. Ejecuta pip install -r requirements.txt dentro de la carpeta del proyecto para restaurar todas las librerías necesarias.
  • "Python no se reconoce como un comando": Este error ocurre cuando la ruta de Python no está registrada en las variables de entorno del sistema. Reinstala Python y asegúrate de marcar la casilla "Add Python to PATH".
  • Error de Permisos: Si el script no puede escribir los archivos en el disco, verifica que la carpeta de destino no tenga atributos de "Solo lectura" o ejecuta la terminal con privilegios de administrador.
  • Falta de FFmpeg: Aunque el script es capaz de gestionar archivos FLAC nativos, tener instalado FFmpeg en el sistema (sudo apt install ffmpeg o mediante el instalador de Windows) mejora significativamente el procesamiento de metadatos complejos y la conversión de formatos.
  • Timeout de Red: Si la descarga se interrumpe, podría deberse a una latencia elevada. El script incluye reintentos automáticos; no obstante, una conexión VPN estable suele estabilizar la comunicación con los servidores de origen.

✅ Verificación de la instalación

Tras ejecutar el script, una interfaz gráfica (GUI) aparecerá en pantalla. Para verificar que el sistema está configurado correctamente, inserta una URL de pista individual en el campo de búsqueda. Si el archivo se descarga con la extensión .flac y los metadatos (artista, álbum, portada) se visualizan correctamente en tu reproductor de audio, tu entorno está listo para gestionar tu biblioteca local de alta fidelidad.

Librerías Necesarias

py -m pip install requests pycryptodome mutagen customtkinter pillow

Código Fuente Principal

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""

la mejor calidad posible (FLAC Lossless / Hi-Res automático y sin complicaciones)
usando una implementación Python pura del protocolo cifrado "eapi/weapi"
y un motor integrado de desbloqueo FLAC sin necesidad obligatoria de cuenta VIP.

  - Auto-contenido: requests, pycryptodome, mutagen y customtkinter (para la GUI).
  - Descubre canciones por búsqueda, id, URL, en lista, álbum o artista.
  - Descarga FLAC/LOSSLESS 24-bit / 16-bit real (archivos completos de 20MB a 85MB).
  - Motor automático de desbloqueo FLAC para canciones VIP.
  - Detección estricta de fragmentos de prueba (evita guardar previews incompletos de 30s).
  - Etiqueta FLAC/MP3 (portada HD, título, artista, álbum, nº) y guarda letras .lrc y M3U.
  - Modo dual: Interfaz Gráfica Moderna (GUI) o Línea de Comandos (CLI).
"""

import sys
import os
import io
import re
import json
import time
import random
import string
import hashlib
import base64
import binascii
import argparse
import logging
import threading
import functools
import subprocess
import urllib.parse
import concurrent.futures as fut
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse, parse_qs, quote
from dataclasses import dataclass, field
from typing import List, Optional, Tuple, Dict, Any

import requests
from requests.adapters import HTTPAdapter

from Crypto.Cipher import AES
from Crypto.PublicKey import RSA

from mutagen.flac import FLAC, Picture
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK, TDRC, TCON, COMM
from mutagen.mp3 import MP3

log = logging.getLogger("ncm-dl")

# ---------------------------------------------------------------------------
# Constantes tomadas de  (util/crypto.js)
# ---------------------------------------------------------------------------
PRESET_KEY = b"0CoJUm6Qyw8W8jud"   # preset AES-CBC key (weapi)
IV = b"0102030405060708"          # IV literal 16 bytes (no es hex)
EAPI_KEY = b"e82ckenh8dichen8"    # clave AES-ECB del protocolo eapi
EAPI_SALT = "-36cd479b6b5-"
BASE62 = string.digits + string.ascii_lowercase + string.ascii_uppercase

# RSA public key (1024 bits) para encSecKey del weapi.
RSA_PUB_PEM = (
    "-----BEGIN PUBLIC KEY-----\n"
    "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB\n"
    "-----END PUBLIC KEY-----"
)
RSA_PUB = RSA.import_key(RSA_PUB_PEM.encode())
RSA_N = RSA_PUB.n
RSA_E = RSA_PUB.e

# Dominios y cabeceras
EAPI_HOST = "https://interfacepc.music.163.com"
UA = ("Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like "
      "Gecko) Chrome/91.0.4472.164 Safari/537.36 NeteaseMusicDesktop/3.1.29.205117")

# IPs chinas para X-Real-IP (mitigar bloqueo regional)
CN_IPS = [
    "120.24.82.57", "220.181.38.149", "221.228.34.138", "27.158.129.14",
    "117.61.127.158", "223.167.7.33", "101.231.123.59", "113.98.112.24",
]


# ---------------------------------------------------------------------------
# Criptografía: eapi y weapi
# ---------------------------------------------------------------------------
def _pad_pkcs7(data: bytes, block=16) -> bytes:
    rem = len(data) % block
    plen = block - rem if rem else block
    return data + bytes([plen]) * plen


def aes_cbc_encrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
    return AES.new(key, AES.MODE_CBC, iv).encrypt(_pad_pkcs7(data))


def aes_ecb_encrypt_hex(key: bytes, data: bytes) -> str:
    return binascii.hexlify(AES.new(key, AES.MODE_ECB).encrypt(_pad_pkcs7(data))).decode().upper()


def aes_ecb_decrypt(key: bytes, data: bytes) -> bytes:
    return AES.new(key, AES.MODE_ECB).decrypt(data)


def rsa_encrypt_hex(plain: str) -> str:
    """Raw RSA (sin relleno) como node-forge 'NONE': modpow -> 1024 bits hex."""
    m = int.from_bytes(plain.encode("utf-8"), "big")
    c = pow(m, RSA_E, RSA_N)
    return c.to_bytes((RSA_N.bit_length() + 7) // 8, "big").hex().upper()


def _js_dumps(obj) -> str:
    """Equivalente a JSON.stringify de Node (compacto, sin espacios)."""
    return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))


def weapi(payload: dict) -> dict:
    """Protocolo weapi -> {params, encSecKey} usado por el API web."""
    text = _js_dumps(payload).encode("utf-8")
    secret = "".join(random.choice(BASE62) for _ in range(16))
    first = aes_cbc_encrypt(PRESET_KEY, IV, text)
    params = base64.b64encode(aes_cbc_encrypt(secret.encode(), IV, first)).decode()
    return {"params": params, "encSecKey": rsa_encrypt_hex(secret[::-1])}


def eapi(route: str, payload: dict) -> str:
    """Protocolo eapi -> params (AES-ECB hex) para el API de PC/iOS."""
    text = _js_dumps(payload)
    digest = hashlib.md5(("nobody" + route + "use" + text + "md5forencrypt").encode("utf-8")).hexdigest()
    data = route + EAPI_SALT + text + EAPI_SALT + digest
    return aes_ecb_encrypt_hex(EAPI_KEY, data.encode("utf-8"))


def eapi_res_decrypt(hex_str: str):
    try:
        raw = aes_ecb_decrypt(EAPI_KEY, binascii.unhexlify(hex_str))
        return json.loads(raw.decode("utf-8"))
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Cliente HTTP cifrado + módulos del API
# ---------------------------------------------------------------------------
class NeteaseError(Exception):
    pass


class NeteaseClient:
    def __init__(self, cookie=None, timeout=25, use_cnip=True):
        self.timeout = timeout
        self.cookie = (cookie or "").strip()
        s = requests.Session()
        adapter = HTTPAdapter(pool_connections=8, pool_maxsize=64, max_retries=2)
        s.mount("https://", adapter)
        s.mount("http://", adapter)
        self.session = s
        self._cn = random.choice(CN_IPS) if use_cnip else None

    def _build_header(self):
        now_ms = int(time.time() * 1000)
        return {
            "osver": "Microsoft-Windows-10-Professional-build-19045-64bit",
            "deviceId": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718",
            "os": "pc",
            "appver": "3.1.17.20450",
            "versioncode": "96",
            "mobilename": "",
            "buildver": datetime.now().strftime("%Y%m%d"),
            "resolution": "1920x1080",
            "__csrf": "",
            "channel": "netease",
            "requestId": "%d_%04d" % (now_ms, random.randint(0, 9999)),
        }

    def _cookie_header(self, header: dict) -> str:
        parts = []
        for k, v in header.items():
            parts.append(k + "=" + quote(str(v), safe=""))
        if self.cookie:
            parts.append(self.cookie)
        return "; ".join(p for p in parts if p)

    def _headers(self, header: dict) -> dict:
        h = {
            "User-Agent": UA,
            "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
            "Referer": "https://music.163.com/",
            "Origin": "https://music.163.com",
            "Cookie": self._cookie_header(header),
        }
        if self._cn:
            h["X-Real-IP"] = self._cn
            h["X-Forwarded-For"] = self._cn
        return h

    def api(self, route: str, data: dict, expect=("code",), retries=4):
        """Llamada eapi -> dict JSON ya descifrado/parseado."""
        header = self._build_header()
        payload = dict(data)
        payload["header"] = header
        params = eapi(route, payload)
        url = EAPI_HOST + "/eapi" + route[len("/api"):]
        last_err = None
        for attempt in range(retries):
            try:
                r = self.session.post(url, data={"params": params},
                                      headers=self._headers(header),
                                      timeout=self.timeout)
                text = r.text
                obj = None
                if text.strip().startswith("{"):
                    try:
                        obj = r.json()
                    except Exception:
                        obj = None
                if obj is None:
                    obj = eapi_res_decrypt(text.strip())
                if obj is None and text.strip():
                    raise ValueError("no json / encrypted parse failed")
                if isinstance(obj, dict) and obj.get("code") not in (200, None):
                    code = obj.get("code")
                    if code in (404,) and route.startswith("/api/v1/album"):
                        return obj
                    if code in (201, 301, 302, 400, 403, 800, 801, 802, 803):
                        time.sleep(1.2 * (attempt + 1))
                        continue
                return obj if obj is not None else {}
            except Exception as exc:
                last_err = exc
                time.sleep(1.5 * (attempt + 1))
        raise NeteaseError("API request failed: %s" % last_err)

    def search(self, keywords, limit=30, offset=0, stype=1):
        return self.api("/api/cloudsearch/pc",
                        {"s": keywords, "type": stype, "limit": limit,
                         "offset": offset, "total": True, "csrf_token": ""})

    def song_detail(self, ids):
        lst = ",".join('{"id":%s}' % i for i in _tolist(ids))
        return self.api("/api/v3/song/detail", {"c": "[" + lst + "]"})

    def song_url_v1(self, ids, level="lossless", encode_type="flac"):
        """Endpoint moderno /v1 de NetEase que entrega FLAC/Lossless real."""
        id_list = [int(x) for x in _tolist(ids)]
        return self.api("/api/song/enhance/player/url/v1",
                        {"ids": json.dumps(id_list),
                         "level": level,
                         "encodeType": encode_type})

    def album(self, album_id):
        return self.api("/api/v1/album/" + str(album_id), {}, expect=("code",))

    def artist_songs(self, artist_id, limit=100, offset=0):
        return self.api("/api/v1/artist/songs",
                        {"id": artist_id, "private_cloud": "true",
                         "work_type": 1, "order": "hot", "offset": offset,
                         "limit": limit})

    def playlist_detail(self, playlist_id):
        return self.api("/api/v6/playlist/detail",
                        {"id": playlist_id, "n": 100000, "s": 8})

    def lyric(self, song_id):
        return self.api("/api/song/lyric",
                        {"id": song_id, "tv": -1, "lv": -1, "rv": -1,
                         "kv": -1, "_nmclfl": 1})


def _tolist(x):
    if isinstance(x, (list, tuple)):
        return list(x)
    return [x]


# ---------------------------------------------------------------------------
# Directorio Seguro de Descarga
# ---------------------------------------------------------------------------
def get_safe_download_dir() -> Path:
    """Calcula una carpeta de descargas segura y con permisos de escritura garantizados."""
    try:
        script_dir = Path(__file__).resolve().parent
        candidate = script_dir / "downloads"
        candidate.mkdir(parents=True, exist_ok=True)
        test_file = candidate / ".test_write"
        test_file.touch()
        test_file.unlink()
        return candidate
    except Exception:
        pass

    try:
        user_dl = Path.home() / "Downloads" / "NetEase_Music"
        user_dl.mkdir(parents=True, exist_ok=True)
        return user_dl
    except Exception:
        pass

    try:
        user_music = Path.home() / "Music" / "NetEase_Music"
        user_music.mkdir(parents=True, exist_ok=True)
        return user_music
    except Exception:
        pass

    return Path.home() / "Downloads"


# ---------------------------------------------------------------------------
# Modelo de canción
# ---------------------------------------------------------------------------
class Song:
    required = ("id", "name", "ar", "al")

    def __init__(self, raw):
        self.raw = raw
        self.id = int(raw.get("id") or 0)
        self.title = (raw.get("name") or "desconocido").strip()
        self.artists = [a.get("name", "") for a in (raw.get("ar") or [])]
        self.artist = ", ".join(a for a in self.artists if a) or "desconocido"
        alb = raw.get("al") or {}
        self.album = (alb.get("name") or "desconocido").strip()
        self.album_id = alb.get("id")
        self.pic = (alb.get("picUrl") or "").replace("http://", "https://")
        self.duration = raw.get("dt") or 0
        self.track = raw.get("st")
        self.fee = raw.get("fee")
        self.flags = raw.get("hMusic") or raw.get("mMusic") or raw.get("lMusic")
        
        # Información de FLAC disponible en NetEase
        sq_info = raw.get("sq") or raw.get("sqMusic") or {}
        hr_info = raw.get("hr") or raw.get("hrMusic") or {}
        self.has_flac = bool(sq_info or hr_info)
        self.flac_size_mb = round(sq_info.get("size", 0) / (1024 * 1024), 1) if sq_info.get("size") else 0

    @property
    def display(self):
        return "%s - %s" % (self.artist, self.title)

    @property
    def duration_str(self):
        secs = (self.duration or 0) // 1000
        mins, s = divmod(secs, 60)
        return f"{mins:02d}:{s:02d}"

    def as_json(self):
        return {"id": self.id, "title": self.title, "artist": self.artist,
                "album": self.album, "album_id": self.album_id, "pic": self.pic,
                "duration_ms": self.duration, "track": self.track, "fee": self.fee}


# ---------------------------------------------------------------------------
# Utilidades de fichero / etiquetado
# ---------------------------------------------------------------------------
_ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]')


def _sanitize(name: str) -> str:
    name = _ILLEGAL.sub("_", name).strip().strip(".")
    name = re.sub(r"\s+", " ", name)
    return name[:180] or "unknown"


def detect_format(header_bytes: bytes, url: str = "") -> str:
    """Determina formato real a partir del magic del archivo."""
    if len(header_bytes) >= 4 and header_bytes[:4] == b"fLaC":
        return "flac"
    if len(header_bytes) >= 4 and header_bytes[:4] in (b"RIFF",):
        return "wav"
    if len(header_bytes) >= 2 and header_bytes[0] == 0xFF and (header_bytes[1] & 0xE0) == 0xE0:
        return "mp3"
    if len(header_bytes) >= 3 and header_bytes[:3] == b"ID3":
        return "mp3"
    low = url.lower()
    if ".flac" in low:
        return "flac"
    if ".m4a" in low or ".aac" in low:
        return "m4a"
    if ".mp3" in low:
        return "mp3"
    return "mp3"


def _cover_bytes(client, pic_url: str) -> bytes:
    if not pic_url:
        return b""
    try:
        r = client.session.get(pic_url, headers={"User-Agent": UA}, timeout=30)
        if r.status_code == 200:
            return r.content if len(r.content) > 100 else b""
        return b""
    except Exception:
        return b""


def _tag_flac(path, meta: dict, cover: bytes, fmt: str):
    try:
        audio = FLAC(path)
        audio["title"] = meta["title"]
        audio["artist"] = meta["artist"]
        audio["album"] = meta["album"]
        audio["albumartist"] = meta["albumartist"]
        audio["genre"] = meta.get("genre", "")
        audio["date"] = meta.get("date", "")
        audio["tracknumber"] = meta.get("tracknumber", "")
        audio["comment"] = "Descargado con netease_downloader.py (id=%s)" % meta["id"]
        if cover:
            pic = Picture()
            pic.type = 3
            if cover[:3] == b"\xff\xd8":
                pic.mime = "image/jpeg"
            elif cover[:4] == b"\x89PNG":
                pic.mime = "image/png"
            else:
                pic.mime = "image/jpeg"
            pic.data = cover
            audio.clear_pictures()
            audio.add_picture(pic)
        audio.save()
    except Exception as e:
        log.debug("Error etiquetando FLAC: %s", e)


def _tag_mp3(path, meta: dict, cover: bytes, fmt: str):
    try:
        id3 = ID3(path)
    except Exception:
        id3 = ID3()
    try:
        id3.add(TIT2(encoding=3, text=meta["title"]))
        id3.add(TPE1(encoding=3, text=meta["artist"]))
        id3.add(TALB(encoding=3, text=meta["album"]))
        if meta.get("tracknumber"):
            id3.add(TRCK(encoding=3, text=meta["tracknumber"]))
        if meta.get("date"):
            id3.add(TDRC(encoding=3, text=meta["date"]))
        if cover:
            id3.add(APIC(encoding=3, mime="image/jpeg", type=3,
                         desc="cover", data=cover))
        id3.save(path)
    except Exception as e:
        log.debug("Error etiquetando MP3: %s", e)


def tag_file(path, meta: dict, cover: bytes, fmt: str):
    if fmt == "flac":
        _tag_flac(path, meta, cover, fmt)
    else:
        _tag_mp3(path, meta, cover, fmt)


# ---------------------------------------------------------------------------
# Opciones globales de descarga
# ---------------------------------------------------------------------------
@dataclass
class Opts:
    quality: str = "flac"
    tags: bool = True
    cover: bool = True
    lyrics: bool = True
    flac_only: bool = False
    overwrite: bool = False
    workers: int = 1
    delay: float = 0.0
    cookie: str = ""
    make_m3u: bool = True


OPTS = Opts()

LEVEL_MAP = {
    "auto": "lossless",
    "flac": "lossless",
    "lossless": "lossless",
    "hires": "hires",
    "jymaster": "jymaster",
    "exhigh": "exhigh",
    "higher": "higher",
    "standard": "standard"
}


# ---------------------------------------------------------------------------
# Motor de Desbloqueo FLAC y Resolución de URL (Unblock FLAC Engine)
# ---------------------------------------------------------------------------
def _match_unblock_flac_api(song_id: int) -> Optional[dict]:
    """
    Desbloquea y resuelve directamente el enlace FLAC Lossless oficial (20MB-85MB)
    a través de los proxies y espejos de desbloqueo de alta fidelidad.
    """
    unblock_urls = [
        f"https://music-api.gdstudio.xyz/api.php?types=url&id={song_id}&source=netease",
        f"https://api.injahow.cn/meting/?type=url&id={song_id}"
    ]
    for u in unblock_urls:
        try:
            r = requests.get(u, headers={"User-Agent": "Mozilla/5.0"}, timeout=6)
            if r.status_code == 200:
                data = r.json()
                stream_url = data.get("url")
                if stream_url and stream_url.startswith("http"):
                    # Comprobar cabecera / tamaño del archivo real
                    h = requests.get(stream_url, headers={"User-Agent": "Mozilla/5.0"}, stream=True, timeout=6)
                    if h.status_code in (200, 206):
                        cl = int(h.headers.get("Content-Length", 0))
                        head_bytes = next(h.iter_content(32))
                        detected_fmt = detect_format(head_bytes, stream_url)
                        is_flac = detected_fmt == "flac" or cl > 18000000
                        if cl > 1500000: # Archivo completo mayor a 1.5MB
                            return {
                                "url": stream_url,
                                "level": "lossless" if is_flac else "exhigh",
                                "format": "flac" if is_flac else "mp3",
                                "source": f"Unblock Engine ({'FLAC Lossless ' + str(round(cl/(1024*1024),1)) + 'MB' if is_flac else 'MP3 320k'})",
                                "size": cl,
                                "is_flac": is_flac
                            }
        except Exception:
            pass
    return None


def _match_kuwo_fallback(song: Song, prefer_flac=True) -> Optional[dict]:
    """Busca y resuelve audio completo (FLAC o 320k MP3) vía motor Kuwo."""
    query = f"{song.title} {song.artist}".strip()
    encoded = urllib.parse.quote(query)
    s_url = f"http://search.kuwo.cn/r.s?all={encoded}&ft=music&itemset=web_2013&client=kt&pn=0&rn=5&rformat=json&encoding=utf8"
    try:
        r = requests.get(s_url, headers={"User-Agent": "okhttp/3.10.0"}, timeout=4)
        data = json.loads(r.text.replace("'", '"'))
        items = data.get("abslist", [])
        for it in items[:3]:
            rid = it.get("MUSICRID", "").replace("MUSIC_", "")
            formats = ["2000kflac", "flac", "320kmp3", "128kmp3"] if prefer_flac else ["320kmp3", "128kmp3", "flac"]
            for fmt in formats:
                u_api = f"http://antiserver.kuwo.cn/anti.s?type=convert_url&rid={rid}&format={fmt}&response=url"
                res = requests.get(u_api, headers={"User-Agent": "okhttp/3.10.0"}, timeout=3)
                if res.text.startswith("http"):
                    try:
                        h = requests.head(res.text, headers={"User-Agent": "okhttp/3.10.0"}, timeout=3)
                        cl = int(h.headers.get("Content-Length", 0))
                        if cl > 1500000:
                            is_flac = "flac" in fmt or cl > 15000000
                            return {
                                "url": res.text,
                                "level": "lossless" if is_flac else "320k",
                                "format": "flac" if is_flac else "mp3",
                                "source": f"Kuwo ({'FLAC Lossless' if is_flac else 'MP3 320k'})",
                                "size": cl,
                                "is_flac": is_flac
                            }
                    except Exception:
                        pass
    except Exception:
        pass
    return None


def _match_netease_outer(song_id: int) -> Optional[dict]:
    """Intenta obtener stream directo no protegido desde el CDN de NetEase."""
    url = f"http://music.163.com/song/media/outer/url?id={song_id}.mp3"
    try:
        r = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, allow_redirects=False, timeout=4)
        if r.status_code == 302:
            loc = r.headers.get("Location", "")
            if "music.126.net" in loc and "404" not in loc:
                h = requests.head(loc, headers={"User-Agent": "Mozilla/5.0"}, timeout=4)
                cl = int(h.headers.get("Content-Length", 0))
                if cl > 1500000:
                    return {
                        "url": loc,
                        "level": "exhigh",
                        "format": "mp3",
                        "source": "NetEase Outer CDN (MP3 320k)",
                        "size": cl,
                        "is_flac": False
                    }
    except Exception:
        pass
    return None


def resolve_song_audio(client: NeteaseClient, song: Song, requested_quality="flac") -> dict:
    """
    Resuelve la mejor URL de audio COMPLETA posible:
    1. Si se solicita FLAC, consulta primero el Motor de Desbloqueo FLAC para obtener FLAC real (20MB-85MB).
    2. Consulta el API v1 de calidad pedida.
    3. Si entrega MP3 o fragmento, busca FLAC en fuentes alternativas.
    4. Garantiza que NO se guarde un archivo de muestra/snippet.
    """
    res = {
        "ok": False,
        "url": None,
        "level": "",
        "format": "mp3",
        "size": 0,
        "source": "NetEase API",
        "is_flac": False,
        "reason": ""
    }

    level = LEVEL_MAP.get(requested_quality, "lossless")
    prefer_flac = requested_quality in ("auto", "flac", "lossless", "hires", "jymaster")

    # 1. Si el usuario pide FLAC, intentar primero el Motor de Desbloqueo FLAC Lossless
    if prefer_flac:
        unblock_res = _match_unblock_flac_api(song.id)
        if unblock_res and unblock_res.get("is_flac"):
            res.update(unblock_res)
            res["ok"] = True
            return res

    # 2. Intentar NetEase v1 oficial con máxima calidad
    item = None
    try:
        v1_data = client.song_url_v1([song.id], level=level, encode_type="flac")
        rows = (v1_data or {}).get("data") or []
        item = next((it for it in rows if it.get("url")), None)
    except Exception as exc:
        log.debug("Error en llamada a NetEase v1: %s", exc)

    if item and item.get("url"):
        stream_url = item["url"].replace("http://", "https://", 1)
        size = int(item.get("size") or 0)
        item_level = str(item.get("level") or "")
        item_type = str(item.get("type") or "mp3").lower()
        free_trial = bool(item.get("freeTrialInfo")) or bool(item.get("freeTimeTrialPrivilege", {}).get("remainTime"))

        is_preview_snippet = free_trial or (song.duration > 60000 and size < 950000 and size > 0)
        is_flac = (item_type == "flac" or "flac" in stream_url or item_level in ("lossless", "hires", "jymaster")) and not is_preview_snippet

        if is_flac and not is_preview_snippet:
            res["ok"] = True
            res["url"] = stream_url
            res["level"] = item_level or "lossless"
            res["format"] = "flac"
            res["is_flac"] = True
            res["size"] = size
            res["source"] = f"NetEase VIP Oficial (FLAC Lossless {round(size/(1024*1024),1)}MB)"
            return res
        elif not is_preview_snippet:
            # Si el usuario pidió específicamente FLAC, intentamos resolver FLAC con los espejos de desbloqueo
            if prefer_flac:
                unblock_res = _match_unblock_flac_api(song.id)
                if unblock_res and unblock_res.get("is_flac"):
                    res.update(unblock_res)
                    res["ok"] = True
                    return res

                kuwo_flac = _match_kuwo_fallback(song, prefer_flac=True)
                if kuwo_flac and kuwo_flac.get("is_flac"):
                    res.update(kuwo_flac)
                    res["ok"] = True
                    return res

            res["ok"] = True
            res["url"] = stream_url
            res["level"] = item_level or "exhigh"
            res["format"] = "mp3"
            res["is_flac"] = False
            res["size"] = size
            res["source"] = f"NetEase Oficial ({item_level.upper()} 320k - FLAC requiere VIP)"
            return res

    # 3. Fallback: Desbloqueo general o Kuwo Matcher
    unblock_res = _match_unblock_flac_api(song.id)
    if unblock_res:
        res.update(unblock_res)
        res["ok"] = True
        return res

    kuwo = _match_kuwo_fallback(song, prefer_flac=prefer_flac)
    if kuwo:
        res.update(kuwo)
        res["ok"] = True
        return res

    # 4. Fallback: NetEase Outer Direct CDN
    outer = _match_netease_outer(song.id)
    if outer:
        res.update(outer)
        res["ok"] = True
        return res

    # 5. Si no se pudo obtener versión completa
    if song.fee in (1, 4, 8) or (item and bool(item.get("freeTrialInfo"))):
        res["reason"] = ("Pista exclusiva VIP (NetEase solo entrega muestra de 30s sin membresía VIP). "
                         "Para descargar en FLAC Lossless, añade tu cookie MUSIC_U en Configuración.")
    else:
        res["reason"] = "Sin URL disponible (bloqueo regional o pista no disponible en servidores públicos)"

    return res


# ---------------------------------------------------------------------------
# Descarga de Canción Individual
# ---------------------------------------------------------------------------
def fetch_song(client: NeteaseClient, song: Song, out_dir: Path, index=0, progress_cb=None, cancel_event=None) -> dict:
    res = {
        "id": song.id,
        "title": song.title,
        "artist": song.artist,
        "ok": False,
        "reason": "",
        "format": None,
        "path": "",
        "level": "",
        "source": ""
    }

    if cancel_event and cancel_event.is_set():
        res["reason"] = "Descarga cancelada"
        return res

    # Asegurar que la carpeta existe
    try:
        out_dir = Path(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)
    except Exception:
        out_dir = get_safe_download_dir()
        out_dir.mkdir(parents=True, exist_ok=True)

    # 1. Resolver enlace de audio completo
    if progress_cb:
        progress_cb(f"Buscando calidad máxima (FLAC) para: {song.display}...", "info")

    stream_info = resolve_song_audio(client, song, requested_quality=OPTS.quality)
    if not stream_info.get("ok") or not stream_info.get("url"):
        res["reason"] = stream_info.get("reason", "No se encontró enlace reproducible completo")
        return res

    stream_url = stream_info["url"]
    res["level"] = stream_info.get("level", "")
    res["source"] = stream_info.get("source", "")
    is_flac = stream_info.get("is_flac", False)

    # Comprobación de modo estricto FLAC
    if OPTS.flac_only and not is_flac:
        res["reason"] = "No hay archivo FLAC disponible para esta pista (--flac-only activado)"
        return res

    fname = "%04d - %s - %s" % (index, song.artist, song.title) if index \
        else "%s - %s" % (song.artist, song.title)
    fname = _sanitize(fname)

    # Comprobar si ya existe archivo completo
    existing = list(out_dir.glob(fname + ".*"))
    if existing and not OPTS.overwrite:
        if existing[0].stat().st_size > 1000000:
            res.update({
                "ok": True,
                "path": str(existing[0]),
                "format": existing[0].suffix.lstrip("."),
                "reason": "Ya existe (omitido)"
            })
            if progress_cb:
                progress_cb(f"Ya existe en disco: {fname}", "info")
            return res

    tmp = out_dir / (fname + ".part")
    headers = {"User-Agent": UA, "Referer": "https://music.163.com/"}
    if tmp.exists() and tmp.stat().st_size > 0:
        headers["Range"] = "bytes=%d-" % tmp.stat().st_size

    if progress_cb:
        source_label = f" [{res['source']}]" if res['source'] else ""
        progress_cb(f"Descargando: {song.display}{source_label}", "info")

    try:
        with client.session.get(stream_url, headers=headers, stream=True,
                                timeout=(15, 90)) as r:
            if r.status_code in (200, 206):
                mode = "ab" if r.status_code == 206 else "wb"
                with open(tmp, mode) as fh:
                    for chunk in r.iter_content(1 << 16):
                        if cancel_event and cancel_event.is_set():
                            res["reason"] = "Cancelado por el usuario"
                            try:
                                tmp.unlink()
                            except Exception:
                                pass
                            return res
                        if chunk:
                            fh.write(chunk)
            elif r.status_code != 416:
                res["reason"] = "HTTP %s al descargar" % r.status_code
                return res
    except Exception as exc:
        res["reason"] = "Error de conexión en descarga: %s" % exc
        return res

    # Validación de integridad de archivo (> 1MB)
    if not tmp.exists() or tmp.stat().st_size < 1000000:
        res["reason"] = "El archivo descargado está incompleto o es un fragmento de prueba (< 1MB)"
        try:
            tmp.unlink()
        except FileNotFoundError:
            pass
        return res

    # Detectar formato real a partir de los bytes
    fmt = detect_format(tmp.read_bytes()[:16], stream_url)
    if OPTS.quality in ("flac", "lossless", "hires") and fmt != "flac":
        if OPTS.flac_only:
            tmp.unlink()
            res["reason"] = "No es formato FLAC verdadero (--flac-only activado)"
            return res

    final = out_dir / (fname + "." + fmt)
    if final.exists() and OPTS.overwrite:
        try:
            final.unlink()
        except Exception:
            pass
    tmp.replace(final)

    # 2. Incrustar metadatos y portada
    if OPTS.tags:
        if progress_cb:
            progress_cb(f"Incrustando portada y etiquetas en {fmt.upper()}...", "info")
        cover = _cover_bytes(client, song.pic) if OPTS.cover else b""
        meta = {
            "id": song.id, "title": song.title, "artist": song.artist,
            "album": song.album,
            "albumartist": (song.artists[0] if song.artists else ""),
            "genre": "", "date": "",
            "tracknumber": str(index) if index else "",
        }
        try:
            tag_file(str(final), meta, cover, fmt)
        except Exception as exc:
            log.debug("Etiquetado falló %s: %s", fname, exc)

    # 3. Guardar letra .lrc sincronizada
    if OPTS.lyrics:
        try:
            lyr = ((client.lyric(song.id) or {}).get("lrc") or {}).get("lyric")
            if lyr and lyr.strip():
                (out_dir / (fname + ".lrc")).write_text(lyr, encoding="utf-8")
        except Exception:
            pass

    if OPTS.delay > 0:
        time.sleep(OPTS.delay)

    res.update({"ok": True, "format": fmt, "path": str(final)})
    return res


# ---------------------------------------------------------------------------
# Orquestación de descargas
# ---------------------------------------------------------------------------
def _write_m3u(path, files, base_dir):
    base_dir = Path(base_dir)
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("#EXTM3U\n")
        for f in files:
            rel = os.path.relpath(f, base_dir).replace("\\", "/")
            fh.write("#EXTINF:-1,%s\n%s\n" % (quote(os.path.basename(f)), rel))


def run_downloads(client, songs, out_dir, m3u_name=None, start=1, progress_cb=None, cancel_event=None):
    try:
        out_dir = Path(out_dir)
        out_dir.mkdir(parents=True, exist_ok=True)
    except Exception:
        out_dir = get_safe_download_dir()
        out_dir.mkdir(parents=True, exist_ok=True)

    results = []
    locked = threading.Lock()

    def one(args):
        c, song, od, i = args
        if cancel_event and cancel_event.is_set():
            return {"id": song.id, "title": song.title, "artist": song.artist,
                    "ok": False, "reason": "Cancelado"}
        r = fetch_song(c, song, od, i, progress_cb=progress_cb, cancel_event=cancel_event)
        with locked:
            results.append(r)
            if progress_cb:
                status_icon = "✅" if r.get("ok") else "❌"
                fmt_info = f" [{r.get('format','').upper()}]" if r.get("ok") else ""
                source_info = f" ({r.get('source','')})" if r.get("ok") and r.get('source') else ""
                reason_info = f": {r.get('reason')}" if not r.get("ok") else ""
                progress_cb(f"[{len(results)}/{len(songs)}] {status_icon} {r['title']}{fmt_info}{source_info}{reason_info}",
                            "ok" if r.get("ok") else "error",
                            pct=len(results)/len(songs))
        return r

    args = [(client, s, out_dir, i) for i, s in enumerate(songs, start)]
    workers = max(1, OPTS.workers)
    if workers == 1:
        for a in args:
            if cancel_event and cancel_event.is_set():
                break
            one(a)
    else:
        with fut.ThreadPoolExecutor(max_workers=workers) as ex:
            list(ex.map(one, args))

    ok = 0
    for r in results:
        if r.get("ok"):
            ok += 1
            log.info("  [OK] %s", r["path"])
        else:
            log.warning("  [--] %s - %s: %s", r["artist"], r["title"], r["reason"])

    if m3u_name and OPTS.make_m3u:
        files = [r["path"] for r in results if r.get("ok") and r.get("path")]
        if files:
            m3u_file = out_dir / (_sanitize(m3u_name) + ".m3u8")
            _write_m3u(m3u_file, files, out_dir)

    return results, len(results) - ok


# ---------------------------------------------------------------------------
# Resolvers: convertir ids / urls / búsquedas en objetos Song
# ---------------------------------------------------------------------------
def parse_netease_url(text: str):
    """Devuelve (tipo, id) a partir de una URL o de un id suelto."""
    text = text.strip()
    if text.isdigit():
        return "song", int(text)
    for kind in ("song", "album", "playlist", "artist"):
        m = re.search(r"/" + kind + r"[/?](?:.*?id=)?(\d+)", text)
        if m:
            return kind, int(m.group(1))
    m = re.search(r"[?&]id=(\d+)", text)
    if m:
        return "song", int(m.group(1))
    return None


def collect_from_ids(client, ids):
    ids = [int(x) for x in ids if str(x).isdigit()]
    out = []
    for chunk in range(0, len(ids), 200):
        data = client.song_detail(ids[chunk:chunk + 200])
        for raw in (data or {}).get("songs") or []:
            out.append(Song(raw))
    return out


def collect_playlist(client, pid):
    data = client.playlist_detail(pid) or {}
    pl = data.get("playlist") or {}
    track_ids = [t.get("id") for t in (pl.get("trackIds") or []) if t.get("id")]
    name = pl.get("name") or ("playlist_%s" % pid)
    songs = collect_from_ids(client, track_ids)
    return songs, name


def collect_album(client, aid):
    data = client.album(aid) or {}
    songs = [Song(r) for r in (data.get("songs") or [])]
    name = (data.get("album") or {}).get("name") or ("album_%s" % aid)
    return songs, name


def collect_artist(client, aid):
    songs, offset, more = [], 0, True
    while more:
        d = client.artist_songs(aid, limit=100, offset=offset) or {}
        batch = d.get("songs") or d.get("hotSongs") or []
        songs += [Song(r) for r in batch]
        more = bool(batch) and offset < 1999
        offset += 100
        if not batch:
            break
    return songs, "artist_%s" % aid



# ---------------------------------------------------------------------------
# Spotify Resolvers y Emparejador Inteligente de Audio FLAC
# ---------------------------------------------------------------------------
def is_spotify_url(text: str) -> bool:
    t = text.strip().lower()
    return "open.spotify.com" in t or t.startswith("spotify:")


def parse_spotify_url(text: str) -> Optional[Tuple[str, str]]:
    """Devuelve (tipo, id) a partir de una URL o URI de Spotify (playlist, album, track)."""
    text = text.strip()
    m = re.search(r'(playlist|album|track)[/:]([a-zA-Z0-9]{22})', text)
    if m:
        return m.group(1), m.group(2)
    return None


def clean_search_query(text: str) -> str:
    cleaned = re.sub(r'[,&/\\(\\)[\]"\'\-]+', ' ', text)
    return re.sub(r'\s+', ' ', cleaned).strip()


def best_netease_match(songs: List[Song], target_title: str, target_artist: str = "") -> Optional[Song]:
    """
    Selecciona la mejor versión entre los resultados de NetEase.
    Penaliza versiones karaoke/tributo e incrementa puntaje por coincidencia de artista y FLAC.
    """
    if not songs:
        return None
    t_low = target_title.lower()
    a_low = target_artist.lower()
    scored = []
    for s in songs:
        score = 0
        s_title_low = s.title.lower()
        s_artist_low = s.artist.lower()
        if any(bad in s_title_low or bad in s_artist_low for bad in ["karaoke", "tribute", "in the style of"]) and not ("karaoke" in t_low or "tribute" in t_low):
            score -= 50
        if a_low:
            artist_tokens = [tok for tok in re.split(r'[,&/\\s]+', a_low) if len(tok) > 2]
            matched_artist = sum(1 for tok in artist_tokens if tok in s_artist_low)
            score += matched_artist * 10
        title_tokens = [tok for tok in re.split(r'[,&/\\s]+', t_low) if len(tok) > 2]
        matched_title = sum(1 for tok in title_tokens if tok in s_title_low)
        score += matched_title * 8
        if s.has_flac:
            score += 6
        scored.append((score, s))
    scored.sort(key=lambda x: x[0], reverse=True)
    return scored[0][1]


def fetch_spotify_tracks(sp_type: str, sp_id: str) -> Tuple[str, List[Dict[str, str]]]:
    """
    Extrae el título y la lista de pistas ({'title': ..., 'artist': ...})
    directamente del embed público de Spotify sin requerir credenciales ni API keys.
    """
    embed_url = f"https://open.spotify.com/embed/{sp_type}/{sp_id}"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
    }
    try:
        r = requests.get(embed_url, headers=headers, timeout=12)
        if r.status_code != 200:
            return f"Spotify_{sp_type}_{sp_id}", []
        m = re.search(r'', r.text)
        if not m:
            return f"Spotify_{sp_type}_{sp_id}", []
        data = json.loads(m.group(1))
        entity = (data.get('props', {}).get('pageProps', {}).get('state', {}).get('data', {}).get('entity') or {})
        name = entity.get('name') or entity.get('title') or f"Spotify_{sp_type}_{sp_id}"
        subtitle = entity.get('subtitle') or ""
        tracks = []
        if sp_type == "track":
            title = entity.get('title') or entity.get('name') or ""
            artist = subtitle
            if isinstance(artist, list):
                artist = ", ".join(str(a) for a in artist if a)
            if not artist and entity.get('artists'):
                artist = ", ".join(a.get('name', '') for a in entity.get('artists', []) if a.get('name'))
            if title:
                tracks.append({"title": title, "artist": str(artist or "")})
        else:
            raw_tracks = entity.get('trackList') or []
            for t in raw_tracks:
                t_title = t.get('title') or t.get('name') or ""
                t_artist = t.get('subtitle') or ""
                if isinstance(t_artist, list):
                    t_artist = ", ".join(str(a) for a in t_artist if a)
                if not t_artist and subtitle and sp_type == "album":
                    t_artist = subtitle
                elif subtitle and subtitle not in t_artist and sp_type == "album":
                    t_artist = f"{subtitle} {t_artist}".strip()
                if t_title:
                    tracks.append({"title": t_title, "artist": str(t_artist or "")})
        return name, tracks
    except Exception as e:
        log.warning("Error extrayendo tracks de Spotify embed: %s", e)
        return f"Spotify_{sp_type}_{sp_id}", []


def match_netease_song(client: NeteaseClient, query_or_dict) -> Optional[Song]:
    """Busca en NetEase la mejor coincidencia para un término o dict {'title', 'artist'}."""
    if isinstance(query_or_dict, dict):
        title = query_or_dict.get("title", "").strip()
        artist = query_or_dict.get("artist", "").strip()
        q = clean_search_query(f"{artist} {title}" if artist else title)
    else:
        title = str(query_or_dict).strip()
        artist = ""
        q = clean_search_query(title)
    if not q:
        return None
    try:
        data = client.search(q, limit=6) or {}
        hits = (data.get("result") or {}).get("songs") or []
        if not hits and artist:
            data = client.search(clean_search_query(title), limit=5) or {}
            hits = (data.get("result") or {}).get("songs") or []
        if not hits:
            return None
        songs = [Song(h) for h in hits]
        return best_netease_match(songs, title, artist)
    except Exception as e:
        log.debug("Error buscando '%s': %s", q, e)
        return None


def resolve_spotify_entity(client: NeteaseClient, sp_type: str, sp_id: str,
                           progress_cb=None, cancel_event=None) -> Tuple[str, List[Song]]:
    """Extrae las pistas de una URL de Spotify y las empareja con canciones FLAC de NetEase."""
    name, sp_tracks = fetch_spotify_tracks(sp_type, sp_id)
    if not sp_tracks:
        return name, []

    if progress_cb:
        progress_cb(f"Spotify: {len(sp_tracks)} pistas detectadas en '{name}'. Buscando versiones en FLAC...", "info", 0.05)

    matched_songs = []
    total = len(sp_tracks)

    def resolve_one(args):
        idx, t = args
        if cancel_event and cancel_event.is_set():
            return None
        s = match_netease_song(client, t)
        if progress_cb:
            pct = 0.05 + 0.90 * (idx / max(1, total))
            info_title = t.get("title", "")
            if s:
                progress_cb(f"[{idx}/{total}] Coincidencia: {s.display} ({'✨ FLAC' if s.has_flac else '320K MP3'})", "info", pct)
            else:
                progress_cb(f"[{idx}/{total}] No encontrada: {info_title}", "warn", pct)
        return s

    with fut.ThreadPoolExecutor(max_workers=5) as ex:
        results = list(ex.map(resolve_one, enumerate(sp_tracks, 1)))

    seen = set()
    for s in results:
        if s and s.id not in seen:
            seen.add(s.id)
            matched_songs.append(s)

    return name, matched_songs


def resolve_mixed_batch_to_songs(client: NeteaseClient, lines: List[str],
                                progress_cb=None, cancel_event=None) -> Tuple[str, List[Song]]:
    """
    Procesa un lote mixto: URLs de Spotify, URLs/IDs de NetEase y líneas de texto ('Artista - Canción').
    Retorna (nombre_lote, lista_canciones_unicas).
    """
    all_songs = []
    batch_title = "lote_descargas"
    lines = [l.strip() for l in lines if l.strip()]
    total_lines = len(lines)

    for i, line in enumerate(lines, 1):
        if cancel_event and cancel_event.is_set():
            break

        # 1. ¿Es URL de Spotify?
        if is_spotify_url(line):
            sp_parsed = parse_spotify_url(line)
            if sp_parsed:
                sp_type, sp_id = sp_parsed
                if progress_cb:
                    progress_cb(f"[{i}/{total_lines}] Analizando Spotify ({sp_type})...", "info", i / total_lines)
                name, s_list = resolve_spotify_entity(client, sp_type, sp_id, progress_cb=progress_cb, cancel_event=cancel_event)
                all_songs.extend(s_list)
                if batch_title == "lote_descargas" and name:
                    batch_title = name
                continue

        # 2. ¿Es URL o ID de NetEase?
        ne_parsed = parse_netease_url(line)
        if ne_parsed:
            kind, tid = ne_parsed
            if progress_cb:
                progress_cb(f"[{i}/{total_lines}] Cargando NetEase {kind} ({tid})...", "info", i / total_lines)
            if kind == "song":
                s = collect_from_ids(client, [tid])
                all_songs.extend(s)
            elif kind == "album":
                s, name = collect_album(client, tid)
                all_songs.extend(s)
                if batch_title == "lote_descargas" and name:
                    batch_title = name
            elif kind == "playlist":
                s, name = collect_playlist(client, tid)
                all_songs.extend(s)
                if batch_title == "lote_descargas" and name:
                    batch_title = name
            elif kind == "artist":
                s, name = collect_artist(client, tid)
                all_songs.extend(s)
                if batch_title == "lote_descargas" and name:
                    batch_title = name
            continue

        if line.isdigit():
            s = collect_from_ids(client, [int(line)])
            all_songs.extend(s)
            continue

        # 3. Línea de texto plano (ej: "Queen - Bohemian Rhapsody")
        if progress_cb:
            progress_cb(f"[{i}/{total_lines}] Buscando: {line}...", "info", i / total_lines)
        s = match_netease_song(client, line)
        if s:
            all_songs.append(s)

    seen = set()
    unique = []
    for s in all_songs:
        if s.id not in seen:
            seen.add(s.id)
            unique.append(s)

    return batch_title, unique


# ---------------------------------------------------------------------------
# Interfaz Gráfica Moderna (CustomTkinter)
# ---------------------------------------------------------------------------
CONFIG_FILE = Path.home() / ".netease_downloader_gui.json"


def load_gui_config() -> dict:
    safe_dir = str(get_safe_download_dir())
    defaults = {
        "out_dir": safe_dir,
        "quality": "flac",
        "tags": True,
        "cover": True,
        "lyrics": True,
        "flac_only": False,
        "overwrite": False,
        "workers": 2,
        "delay": 0.0,
        "cookie": "",
        "make_m3u": True,
        "theme": "Dark"
    }
    if CONFIG_FILE.exists():
        try:
            data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
            saved_dir = str(data.get("out_dir", ""))
            if "system32" in saved_dir.lower() or "windows" in saved_dir.lower() or not saved_dir:
                data["out_dir"] = safe_dir
            defaults.update(data)
        except Exception:
            pass
    return defaults


def save_gui_config(cfg: dict):
    try:
        CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
    except Exception:
        pass


def launch_gui():
    try:
        import customtkinter as ctk
        from tkinter import filedialog, messagebox
    except ImportError as e:
        print(f"Error importando interfaz gráfica: {e}")
        print("Instala dependencias con: pip install customtkinter pillow")
        sys.exit(1)

    cfg = load_gui_config()
    ctk.set_appearance_mode(cfg.get("theme", "Dark"))
    ctk.set_default_color_theme("blue")

    class App(ctk.CTk):
        def __init__(self):
            super().__init__()
            self.title("scriptspython.pro Cloud Music Downloader - FLAC & Lossless")
            self.geometry("1060x800")
            self.minsize(920, 680)

            self.client = NeteaseClient(cookie=cfg.get("cookie", ""))
            self.results_songs: List[Song] = []
            self.song_check_vars: List[ctk.BooleanVar] = []
            
            # Carrito / Cola de descarga persistente
            self.cart_songs: List[Song] = []
            self.cart_song_ids: set = set()

            # Vista previa de lotes
            self.batch_preview_songs: List[Song] = []
            self.batch_preview_check_vars: List[ctk.BooleanVar] = []
            self.batch_preview_name: str = ""

            self.download_thread = None
            self.cancel_event = threading.Event()
            self.is_downloading = False

            self._create_layout()
            self._apply_saved_config()

        def _create_layout(self):
            self.grid_columnconfigure(0, weight=1)
            self.grid_rowconfigure(1, weight=1)
            self.grid_rowconfigure(2, weight=0)

            # === 1. Cabecera (Header) ===
            header = ctk.CTkFrame(self, corner_radius=0, fg_color=("gray85", "gray17"), height=72)
            header.grid(row=0, column=0, sticky="ew", padx=0, pady=0)
            header.grid_columnconfigure(1, weight=1)

            title_label = ctk.CTkLabel(
                header,
                text="🎵 scriptspython.pro Cloud Music Downloader",
                font=ctk.CTkFont(size=20, weight="bold")
            )
            title_label.grid(row=0, column=0, padx=20, pady=(8, 2), sticky="w")

            subtitle = ctk.CTkLabel(
                header,
                text="Descargador de música en máxima calidad (FLAC Lossless 24-bit / 16-bit / 320k) con portadas HD y letras .lrc",
                font=ctk.CTkFont(size=12),
                text_color=("gray40", "gray70")
            )
            subtitle.grid(row=1, column=0, padx=20, pady=(0, 8), sticky="w")

            # Botón directo para ver la Cola / Carrito
            self.btn_header_cart = ctk.CTkButton(
                header,
                text="📥 Mi Cola (0)",
                width=130,
                height=36,
                fg_color="#8e44ad",
                hover_color="#9b59b6",
                font=ctk.CTkFont(size=12, weight="bold"),
                command=lambda: self.tabview.set("📥 Cola de Descarga")
            )
            self.btn_header_cart.grid(row=0, column=2, rowspan=2, padx=(0, 10), pady=10, sticky="e")

            self.btn_quick_folder = ctk.CTkButton(
                header,
                text="📂 Abrir Carpeta",
                width=120,
                height=36,
                command=self._open_downloads_folder
            )
            self.btn_quick_folder.grid(row=0, column=3, rowspan=2, padx=(0, 20), pady=10, sticky="e")

            # === 2. Pestañas Principales ===
            self.tabview = ctk.CTkTabview(self, corner_radius=10)
            self.tabview.grid(row=1, column=0, sticky="nsew", padx=15, pady=(5, 5))

            self.tab_search = self.tabview.add("🔍 Búsqueda y Enlaces")
            self.tab_cart = self.tabview.add("📥 Cola de Descarga")
            self.tab_batch = self.tabview.add("📋 Listas y Lotes (Spotify / TXT)")
            self.tab_settings = self.tabview.add("⚙️ Configuración")

            self._setup_tab_search()
            self._setup_tab_cart()
            self._setup_tab_batch()
            self._setup_tab_settings()

            # === 3. Panel Inferior ===
            bottom_frame = ctk.CTkFrame(self, corner_radius=10)
            bottom_frame.grid(row=2, column=0, sticky="ew", padx=15, pady=(5, 12))
            bottom_frame.grid_columnconfigure(0, weight=1)

            status_bar_frame = ctk.CTkFrame(bottom_frame, fg_color="transparent")
            status_bar_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=(8, 4))
            status_bar_frame.grid_columnconfigure(0, weight=1)

            self.lbl_status = ctk.CTkLabel(
                status_bar_frame,
                text="Listo para descargar canciones en FLAC.",
                font=ctk.CTkFont(size=12, weight="bold"),
                anchor="w"
            )
            self.lbl_status.grid(row=0, column=0, sticky="w")

            self.btn_cancel = ctk.CTkButton(
                status_bar_frame,
                text="⏹️ Cancelar",
                width=90,
                fg_color="#c0392b",
                hover_color="#e74c3c",
                command=self._cancel_download,
                state="disabled"
            )
            self.btn_cancel.grid(row=0, column=1, sticky="e")

            self.prog_bar = ctk.CTkProgressBar(bottom_frame, height=12)
            self.prog_bar.grid(row=1, column=0, sticky="ew", padx=10, pady=(2, 6))
            self.prog_bar.set(0)

            self.log_box = ctk.CTkTextbox(bottom_frame, height=95, font=ctk.CTkFont(family="Consolas", size=11))
            self.log_box.grid(row=2, column=0, sticky="ew", padx=10, pady=(2, 8))
            self.log_box.configure(state="disabled")

        # -------------------------------------------------------------------
        # Pestaña 1: Búsqueda y Enlaces
        # -------------------------------------------------------------------
        def _setup_tab_search(self):
            t = self.tab_search
            t.grid_columnconfigure(0, weight=1)
            t.grid_rowconfigure(1, weight=1)

            search_bar = ctk.CTkFrame(t, fg_color="transparent")
            search_bar.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 10))
            search_bar.grid_columnconfigure(0, weight=1)

            self.entry_query = ctk.CTkEntry(
                search_bar,
                placeholder_text="Buscar canción, artista o pegar enlace de Spotify (playlist, álbum, canción) o NetEase...",
                height=38,
                font=ctk.CTkFont(size=13)
            )
            self.entry_query.grid(row=0, column=0, sticky="ew", padx=(0, 8))
            self.entry_query.bind("", lambda e: self._on_search_clicked())

            self.btn_search = ctk.CTkButton(
                search_bar,
                text="🔍 Buscar",
                width=100,
                height=38,
                command=self._on_search_clicked
            )
            self.btn_search.grid(row=0, column=1, padx=(0, 6))

            self.btn_load_url = ctk.CTkButton(
                search_bar,
                text="🔗 Cargar URL",
                width=115,
                height=38,
                fg_color="#2980b9",
                hover_color="#3498db",
                command=self._on_load_url_clicked
            )
            self.btn_load_url.grid(row=0, column=2, padx=(0, 6))

            self.btn_paste = ctk.CTkButton(
                search_bar,
                text="📋 Pegar",
                width=75,
                height=38,
                fg_color=("gray75", "gray30"),
                text_color=("gray10", "gray90"),
                command=self._paste_clipboard
            )
            self.btn_paste.grid(row=0, column=3)

            results_frame = ctk.CTkFrame(t)
            results_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=0)
            results_frame.grid_columnconfigure(0, weight=1)
            results_frame.grid_rowconfigure(1, weight=1)

            res_toolbar = ctk.CTkFrame(results_frame, fg_color="transparent")
            res_toolbar.grid(row=0, column=0, sticky="ew", padx=10, pady=(8, 4))
            res_toolbar.grid_columnconfigure(4, weight=1)

            self.lbl_results_count = ctk.CTkLabel(
                res_toolbar,
                text="Sin resultados aún.",
                font=ctk.CTkFont(size=12, weight="bold")
            )
            self.lbl_results_count.grid(row=0, column=0, padx=(0, 15), sticky="w")

            btn_sel_all = ctk.CTkButton(
                res_toolbar, text="Seleccionar Todo", width=110, height=26,
                fg_color=("gray75", "gray30"), text_color=("gray10", "gray90"),
                command=self._select_all_results
            )
            btn_sel_all.grid(row=0, column=1, padx=(0, 5))

            btn_desel_all = ctk.CTkButton(
                res_toolbar, text="Deseleccionar", width=95, height=26,
                fg_color=("gray75", "gray30"), text_color=("gray10", "gray90"),
                command=self._deselect_all_results
            )
            btn_desel_all.grid(row=0, column=2, padx=(0, 5))

            self.btn_add_to_cart_sel = ctk.CTkButton(
                res_toolbar,
                text="➕ Añadir Seleccionadas a la Cola",
                width=220,
                height=26,
                fg_color="#8e44ad",
                hover_color="#9b59b6",
                font=ctk.CTkFont(size=11, weight="bold"),
                command=self._add_selected_to_cart
            )
            self.btn_add_to_cart_sel.grid(row=0, column=3, padx=(5, 5))

            btn_clear = ctk.CTkButton(
                res_toolbar, text="🗑️ Limpiar", width=80, height=26,
                fg_color=("gray75", "gray30"), text_color=("gray10", "gray90"),
                command=self._clear_results
            )
            btn_clear.grid(row=0, column=5, sticky="e")

            self.scroll_results = ctk.CTkScrollableFrame(results_frame)
            self.scroll_results.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
            self.scroll_results.grid_columnconfigure(2, weight=1)

            actions_bar = ctk.CTkFrame(t, fg_color="transparent")
            actions_bar.grid(row=2, column=0, sticky="ew", padx=5, pady=(8, 4))
            actions_bar.grid_columnconfigure(0, weight=1)

            self.btn_download_selected = ctk.CTkButton(
                actions_bar,
                text="⬇️ Descargar Seleccionadas (0)",
                height=40,
                font=ctk.CTkFont(size=14, weight="bold"),
                fg_color="#27ae60",
                hover_color="#2ecc71",
                command=self._download_selected_songs
            )
            self.btn_download_selected.grid(row=0, column=0, sticky="ew", padx=(0, 8))

            self.btn_search_go_cart = ctk.CTkButton(
                actions_bar,
                text="📥 Ver Mi Cola de Descarga (0)",
                height=40,
                font=ctk.CTkFont(size=13, weight="bold"),
                width=220,
                fg_color="#8e44ad",
                hover_color="#9b59b6",
                command=lambda: self.tabview.set("📥 Cola de Descarga")
            )
            self.btn_search_go_cart.grid(row=0, column=1, padx=(0, 8))

            self.btn_download_all = ctk.CTkButton(
                actions_bar,
                text="⬇️ Descargar Todo en FLAC",
                height=40,
                font=ctk.CTkFont(size=14, weight="bold"),
                width=190,
                command=self._download_all_results
            )
            self.btn_download_all.grid(row=0, column=2)

        # -------------------------------------------------------------------
        # Pestaña 2: Cola de Descarga (Carrito Persistente Multi-Búsqueda)
        # -------------------------------------------------------------------
        def _setup_tab_cart(self):
            t = self.tab_cart
            t.grid_columnconfigure(0, weight=1)
            t.grid_rowconfigure(1, weight=1)

            cart_toolbar = ctk.CTkFrame(t, fg_color="transparent")
            cart_toolbar.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 8))
            cart_toolbar.grid_columnconfigure(0, weight=1)

            self.lbl_cart_header = ctk.CTkLabel(
                cart_toolbar,
                text="📥 Tu Cola de Descarga está vacía (0 canciones acumuladas).",
                font=ctk.CTkFont(size=13, weight="bold")
            )
            self.lbl_cart_header.grid(row=0, column=0, sticky="w", padx=5)

            btn_clear_cart = ctk.CTkButton(
                cart_toolbar,
                text="🗑️ Vaciar Cola",
                width=110,
                height=30,
                fg_color=("gray75", "gray30"),
                text_color=("gray10", "gray90"),
                command=self._clear_cart
            )
            btn_clear_cart.grid(row=0, column=1, padx=6)

            self.scroll_cart = ctk.CTkScrollableFrame(t)
            self.scroll_cart.grid(row=1, column=0, sticky="nsew", padx=5, pady=0)
            self.scroll_cart.grid_columnconfigure(2, weight=1)

            cart_actions = ctk.CTkFrame(t, fg_color="transparent")
            cart_actions.grid(row=2, column=0, sticky="ew", padx=5, pady=(8, 4))
            cart_actions.grid_columnconfigure(0, weight=1)

            self.btn_download_cart = ctk.CTkButton(
                cart_actions,
                text="⬇️ Descargar Toda la Cola en FLAC (0 canciones)",
                height=42,
                font=ctk.CTkFont(size=14, weight="bold"),
                fg_color="#27ae60",
                hover_color="#2ecc71",
                command=self._download_cart_songs
            )
            self.btn_download_cart.grid(row=0, column=0, sticky="ew")

            self._render_cart_songs()

        # -------------------------------------------------------------------
        # Pestaña 3: Listas y Lotes (Spotify / NetEase / TXT) con Vista Previa
        # -------------------------------------------------------------------
        def _setup_tab_batch(self):
            t = self.tab_batch
            t.grid_columnconfigure(0, weight=1)
            t.grid_rowconfigure(0, weight=1)

            # Contenedor modo 1: Entrada de texto / enlaces
            self.frame_batch_input = ctk.CTkFrame(t, fg_color="transparent")
            self.frame_batch_input.grid(row=0, column=0, sticky="nsew")
            self.frame_batch_input.grid_columnconfigure(0, weight=1)
            self.frame_batch_input.grid_rowconfigure(1, weight=1)

            lbl_info = ctk.CTkLabel(
                self.frame_batch_input,
                text="Pega enlaces de Spotify (Playlists, Álbumes o Canciones), enlaces de NetEase, o nombres de canciones (ej: Artista - Canción), una por línea:",
                anchor="w",
                font=ctk.CTkFont(size=12)
            )
            lbl_info.grid(row=0, column=0, sticky="w", padx=5, pady=(5, 5))

            self.txt_batch = ctk.CTkTextbox(self.frame_batch_input, font=ctk.CTkFont(family="Consolas", size=12))
            self.txt_batch.grid(row=1, column=0, sticky="nsew", padx=5, pady=0)

            batch_actions = ctk.CTkFrame(self.frame_batch_input, fg_color="transparent")
            batch_actions.grid(row=2, column=0, sticky="ew", padx=5, pady=(8, 4))
            batch_actions.grid_columnconfigure(2, weight=1)

            btn_import_file = ctk.CTkButton(
                batch_actions,
                text="📂 Cargar archivo .txt",
                height=36,
                command=self._import_batch_file
            )
            btn_import_file.grid(row=0, column=0, padx=(0, 6))

            btn_clear_batch = ctk.CTkButton(
                batch_actions,
                text="🗑️ Limpiar",
                height=36,
                width=80,
                fg_color=("gray75", "gray30"),
                text_color=("gray10", "gray90"),
                command=lambda: self.txt_batch.delete("1.0", "end")
            )
            btn_clear_batch.grid(row=0, column=1, padx=(0, 6))

            self.btn_preview_batch = ctk.CTkButton(
                batch_actions,
                text="👁️ Previsualizar Lista y Canciones",
                height=38,
                font=ctk.CTkFont(size=13, weight="bold"),
                fg_color="#2980b9",
                hover_color="#3498db",
                command=self._analyze_batch_preview
            )
            self.btn_preview_batch.grid(row=0, column=3, padx=(0, 8), sticky="e")

            self.btn_start_batch = ctk.CTkButton(
                batch_actions,
                text="⬇️ Descargar Directo en FLAC",
                height=38,
                font=ctk.CTkFont(size=13, weight="bold"),
                fg_color="#27ae60",
                hover_color="#2ecc71",
                command=self._start_batch_download
            )
            self.btn_start_batch.grid(row=0, column=4, sticky="e")

            # Contenedor modo 2: Vista Previa Interactiva del Lote
            self.frame_batch_preview = ctk.CTkFrame(t, fg_color="transparent")
            self.frame_batch_preview.grid_columnconfigure(0, weight=1)
            self.frame_batch_preview.grid_rowconfigure(2, weight=1)

            # Barra superior de la vista previa
            prev_header_frame = ctk.CTkFrame(self.frame_batch_preview, fg_color="transparent")
            prev_header_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 2))
            prev_header_frame.grid_columnconfigure(0, weight=1)

            self.lbl_batch_preview_title = ctk.CTkLabel(
                prev_header_frame,
                text="📋 Vista Previa de Lote",
                font=ctk.CTkFont(size=15, weight="bold"),
                anchor="w"
            )
            self.lbl_batch_preview_title.grid(row=0, column=0, sticky="w")

            self.lbl_batch_preview_sub = ctk.CTkLabel(
                prev_header_frame,
                text="Revisa las canciones detectadas. Puedes desmarcar las que no desees, añadirlas a tu Cola o descargarlas ya en FLAC.",
                font=ctk.CTkFont(size=11),
                text_color=("gray40", "gray70"),
                anchor="w"
            )
            self.lbl_batch_preview_sub.grid(row=1, column=0, sticky="w", pady=(1, 0))

            # Toolbar de selección en vista previa
            prev_toolbar = ctk.CTkFrame(self.frame_batch_preview, fg_color="transparent")
            prev_toolbar.grid(row=1, column=0, sticky="ew", padx=5, pady=(4, 4))
            prev_toolbar.grid_columnconfigure(2, weight=1)

            btn_prev_sel_all = ctk.CTkButton(
                prev_toolbar, text="Seleccionar Todo", width=110, height=26,
                fg_color=("gray75", "gray30"), text_color=("gray10", "gray90"),
                command=self._select_all_batch_preview
            )
            btn_prev_sel_all.grid(row=0, column=0, padx=(0, 6))

            btn_prev_desel_all = ctk.CTkButton(
                prev_toolbar, text="Deseleccionar", width=95, height=26,
                fg_color=("gray75", "gray30"), text_color=("gray10", "gray90"),
                command=self._deselect_all_batch_preview
            )
            btn_prev_desel_all.grid(row=0, column=1)

            btn_prev_back = ctk.CTkButton(
                prev_toolbar, text="🔙 Volver a Editar Lista", width=145, height=26,
                fg_color=("gray75", "gray30"), text_color=("gray10", "gray90"),
                command=self._back_to_batch_input
            )
            btn_prev_back.grid(row=0, column=3, sticky="e")

            # Lista scrollable con las canciones de la vista previa
            self.scroll_batch_preview = ctk.CTkScrollableFrame(self.frame_batch_preview)
            self.scroll_batch_preview.grid(row=2, column=0, sticky="nsew", padx=5, pady=0)
            self.scroll_batch_preview.grid_columnconfigure(2, weight=1)

            # Barra inferior de acciones de la vista previa
            prev_bottom = ctk.CTkFrame(self.frame_batch_preview, fg_color="transparent")
            prev_bottom.grid(row=3, column=0, sticky="ew", padx=5, pady=(8, 4))
            prev_bottom.grid_columnconfigure(0, weight=1)

            self.btn_preview_add_cart = ctk.CTkButton(
                prev_bottom,
                text="➕ Añadir Seleccionadas a la Cola (Carrito)",
                height=40,
                font=ctk.CTkFont(size=13, weight="bold"),
                fg_color="#8e44ad",
                hover_color="#9b59b6",
                command=self._add_preview_to_cart
            )
            self.btn_preview_add_cart.grid(row=0, column=0, sticky="ew", padx=(0, 8))

            self.btn_preview_download = ctk.CTkButton(
                prev_bottom,
                text="⬇️ Descargar Seleccionadas en FLAC",
                height=40,
                font=ctk.CTkFont(size=13, weight="bold"),
                width=240,
                fg_color="#27ae60",
                hover_color="#2ecc71",
                command=self._download_preview_selected
            )
            self.btn_preview_download.grid(row=0, column=1)

        # -------------------------------------------------------------------
        # Pestaña 4: Configuración
        # -------------------------------------------------------------------
        def _setup_tab_settings(self):
            t = self.tab_settings
            t.grid_columnconfigure(0, weight=1)

            scroll_settings = ctk.CTkScrollableFrame(t)
            scroll_settings.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
            scroll_settings.grid_columnconfigure(1, weight=1)

            r = 0
            # 1. Carpeta de descargas
            ctk.CTkLabel(scroll_settings, text="Carpeta de Salida:", font=ctk.CTkFont(weight="bold")).grid(row=r, column=0, sticky="w", padx=10, pady=8)
            self.entry_out_dir = ctk.CTkEntry(scroll_settings)
            self.entry_out_dir.grid(row=r, column=1, sticky="ew", padx=(0, 8), pady=8)
            btn_browse = ctk.CTkButton(scroll_settings, text="Examinar...", width=95, command=self._browse_folder)
            btn_browse.grid(row=r, column=2, padx=(0, 10), pady=8)

            r += 1
            # 2. Calidad de audio
            ctk.CTkLabel(scroll_settings, text="Calidad Solicitada:", font=ctk.CTkFont(weight="bold")).grid(row=r, column=0, sticky="w", padx=10, pady=8)
            self.combo_quality = ctk.CTkComboBox(
                scroll_settings,
                values=["flac / lossless (FLAC 24-bit / 16-bit Lossless)", "hires (Hi-Res Master)", "auto (Mejor disponible)", "exhigh (320 kbps MP3)", "higher (192 kbps)", "standard (128 kbps)"],
                width=300
            )
            self.combo_quality.grid(row=r, column=1, sticky="w", padx=0, pady=8)

            r += 1
            # 3. Opciones de contenido
            ctk.CTkLabel(scroll_settings, text="Opciones de Contenido:", font=ctk.CTkFont(weight="bold")).grid(row=r, column=0, sticky="w", padx=10, pady=8)
            opts_box = ctk.CTkFrame(scroll_settings, fg_color="transparent")
            opts_box.grid(row=r, column=1, columnspan=2, sticky="w", pady=8)

            self.sw_tags = ctk.CTkSwitch(opts_box, text="Incrustar etiquetas de metadatos (ID3 / Vorbis)")
            self.sw_tags.grid(row=0, column=0, sticky="w", padx=5, pady=4)

            self.sw_cover = ctk.CTkSwitch(opts_box, text="Incrustar carátula de portada en alta resolución")
            self.sw_cover.grid(row=1, column=0, sticky="w", padx=5, pady=4)

            self.sw_lyrics = ctk.CTkSwitch(opts_box, text="Descargar letras sincronizadas (.lrc)")
            self.sw_lyrics.grid(row=2, column=0, sticky="w", padx=5, pady=4)

            self.sw_m3u = ctk.CTkSwitch(opts_box, text="Generar archivo de lista de reproducción (.m3u8)")
            self.sw_m3u.grid(row=3, column=0, sticky="w", padx=5, pady=4)

            self.sw_flac_only = ctk.CTkSwitch(opts_box, text="Solo guardar si es FLAC verdadero (--flac-only)")
            self.sw_flac_only.grid(row=4, column=0, sticky="w", padx=5, pady=4)

            self.sw_overwrite = ctk.CTkSwitch(opts_box, text="Sobrescribir archivos si ya existen")
            self.sw_overwrite.grid(row=5, column=0, sticky="w", padx=5, pady=4)

            r += 1
            # 4. Cookie VIP
            ctk.CTkLabel(scroll_settings, text="Cookie VIP (Opcional):", font=ctk.CTkFont(weight="bold")).grid(row=r, column=0, sticky="w", padx=10, pady=8)
            cookie_frame = ctk.CTkFrame(scroll_settings, fg_color="transparent")
            cookie_frame.grid(row=r, column=1, columnspan=2, sticky="ew", padx=(0, 10), pady=8)
            cookie_frame.grid_columnconfigure(0, weight=1)

            self.entry_cookie = ctk.CTkEntry(cookie_frame, placeholder_text="MUSIC_U=tu_cookie_vip; (Opcional: el motor integrado ya desbloquea FLAC automáticamente)")
            self.entry_cookie.grid(row=0, column=0, sticky="ew", padx=(0, 8))

            cookie_tip = ctk.CTkLabel(
                cookie_frame,
                text="✨ Desbloqueo FLAC Automático Activado: Resuelve y descarga FLAC Lossless (20MB-85MB) directamente a través del motor integrado sin necesidad obligatoria de ingresar una cookie.",
                font=ctk.CTkFont(size=11),
                text_color=("#27ae60", "#2ecc71"),
                anchor="w",
                justify="left",
                wraplength=600
            )
            cookie_tip.grid(row=1, column=0, sticky="w", pady=(4, 0))

            r += 1
            # 5. Rendimiento
            ctk.CTkLabel(scroll_settings, text="Descargas Simultáneas:", font=ctk.CTkFont(weight="bold")).grid(row=r, column=0, sticky="w", padx=10, pady=8)
            perf_frame = ctk.CTkFrame(scroll_settings, fg_color="transparent")
            perf_frame.grid(row=r, column=1, columnspan=2, sticky="w", pady=8)

            self.combo_workers = ctk.CTkComboBox(perf_frame, values=["1", "2", "3", "4", "6"], width=80)
            self.combo_workers.grid(row=0, column=0, sticky="w", padx=(0, 20))

            ctk.CTkLabel(perf_frame, text="Pausa entre descargas (seg):").grid(row=0, column=1, sticky="w", padx=(0, 8))
            self.entry_delay = ctk.CTkEntry(perf_frame, width=60)
            self.entry_delay.grid(row=0, column=2, sticky="w")

            r += 1
            # 6. Tema de interfaz
            ctk.CTkLabel(scroll_settings, text="Apariencia / Tema:", font=ctk.CTkFont(weight="bold")).grid(row=r, column=0, sticky="w", padx=10, pady=8)
            self.combo_theme = ctk.CTkComboBox(scroll_settings, values=["Dark", "Light", "System"], width=120, command=self._change_theme)
            self.combo_theme.grid(row=r, column=1, sticky="w", pady=8)

            r += 1
            btn_save_cfg = ctk.CTkButton(
                scroll_settings,
                text="💾 Guardar Configuración",
                height=36,
                command=self._save_settings_clicked
            )
            btn_save_cfg.grid(row=r, column=1, sticky="w", pady=(15, 10))

        def _apply_saved_config(self):
            safe_dir = str(get_safe_download_dir())
            saved_dir = str(cfg.get("out_dir", safe_dir))
            if "system32" in saved_dir.lower() or "windows" in saved_dir.lower():
                saved_dir = safe_dir

            self.entry_out_dir.delete(0, "end")
            self.entry_out_dir.insert(0, saved_dir)

            q_map = {
                "flac": "flac / lossless (FLAC 24-bit / 16-bit Lossless)",
                "lossless": "flac / lossless (FLAC 24-bit / 16-bit Lossless)",
                "hires": "hires (Hi-Res Master)",
                "auto": "auto (Mejor disponible)",
                "exhigh": "exhigh (320 kbps MP3)",
                "higher": "higher (192 kbps)",
                "standard": "standard (128 kbps)"
            }
            self.combo_quality.set(q_map.get(cfg.get("quality", "flac"), "flac / lossless (FLAC 24-bit / 16-bit Lossless)"))
            if cfg.get("tags", True): self.sw_tags.select()
            else: self.sw_tags.deselect()

            if cfg.get("cover", True): self.sw_cover.select()
            else: self.sw_cover.deselect()

            if cfg.get("lyrics", True): self.sw_lyrics.select()
            else: self.sw_lyrics.deselect()

            if cfg.get("make_m3u", True): self.sw_m3u.select()
            else: self.sw_m3u.deselect()

            if cfg.get("flac_only", False): self.sw_flac_only.select()
            else: self.sw_flac_only.deselect()

            if cfg.get("overwrite", False): self.sw_overwrite.select()
            else: self.sw_overwrite.deselect()

            self.entry_cookie.insert(0, cfg.get("cookie", ""))
            self.combo_workers.set(str(cfg.get("workers", 2)))
            self.entry_delay.insert(0, str(cfg.get("delay", 0.0)))
            self.combo_theme.set(cfg.get("theme", "Dark"))

        def _change_theme(self, choice):
            ctk.set_appearance_mode(choice)

        def _save_settings_clicked(self):
            q_val = self.combo_quality.get().split()[0].lower()
            try:
                workers_val = int(self.combo_workers.get())
            except ValueError:
                workers_val = 2
            try:
                delay_val = float(self.entry_delay.get())
            except ValueError:
                delay_val = 0.0

            out_dir_val = self.entry_out_dir.get().strip()
            if not out_dir_val or "system32" in out_dir_val.lower() or "windows" in out_dir_val.lower():
                out_dir_val = str(get_safe_download_dir())
                self.entry_out_dir.delete(0, "end")
                self.entry_out_dir.insert(0, out_dir_val)

            cfg["out_dir"] = out_dir_val
            cfg["quality"] = q_val
            cfg["tags"] = bool(self.sw_tags.get())
            cfg["cover"] = bool(self.sw_cover.get())
            cfg["lyrics"] = bool(self.sw_lyrics.get())
            cfg["make_m3u"] = bool(self.sw_m3u.get())
            cfg["flac_only"] = bool(self.sw_flac_only.get())
            cfg["overwrite"] = bool(self.sw_overwrite.get())
            cfg["cookie"] = self.entry_cookie.get().strip()
            cfg["workers"] = workers_val
            cfg["delay"] = delay_val
            cfg["theme"] = self.combo_theme.get()

            save_gui_config(cfg)
            self.client.cookie = cfg["cookie"]
            self._log(f"Configuración guardada. Carpeta de descargas: {out_dir_val}", "info")
            messagebox.showinfo("Configuración", "Los ajustes se han guardado con éxito.")

        def _sync_opts(self):
            q_val = self.combo_quality.get().split()[0].lower()
            try:
                workers_val = int(self.combo_workers.get())
            except ValueError:
                workers_val = 2
            try:
                delay_val = float(self.entry_delay.get())
            except ValueError:
                delay_val = 0.0

            OPTS.quality = q_val
            OPTS.tags = bool(self.sw_tags.get())
            OPTS.cover = bool(self.sw_cover.get())
            OPTS.lyrics = bool(self.sw_lyrics.get())
            OPTS.make_m3u = bool(self.sw_m3u.get())
            OPTS.flac_only = bool(self.sw_flac_only.get())
            OPTS.overwrite = bool(self.sw_overwrite.get())
            OPTS.workers = workers_val
            OPTS.delay = delay_val
            OPTS.cookie = self.entry_cookie.get().strip()

            self.client.cookie = OPTS.cookie

        # -------------------------------------------------------------------
        # Métodos de registro y estado
        # -------------------------------------------------------------------
        def _log(self, message: str, level="info"):
            timestamp = datetime.now().strftime("%H:%M:%S")
            prefix = {"info": "ℹ️ ", "ok": "✅ ", "warn": "⚠️ ", "error": "❌ "}.get(level, "")
            line = f"[{timestamp}] {prefix}{message}\n"

            def append():
                self.log_box.configure(state="normal")
                self.log_box.insert("end", line)
                self.log_box.see("end")
                self.log_box.configure(state="disabled")

            self.after(0, append)

        def _set_status(self, text: str, pct: Optional[float] = None):
            def update():
                self.lbl_status.configure(text=text)
                if pct is not None:
                    self.prog_bar.set(max(0.0, min(1.0, pct)))

            self.after(0, update)

        def _open_downloads_folder(self):
            out_raw = self.entry_out_dir.get().strip()
            if not out_raw or "system32" in out_raw.lower() or "windows" in out_raw.lower():
                folder = get_safe_download_dir()
                self.entry_out_dir.delete(0, "end")
                self.entry_out_dir.insert(0, str(folder))
            else:
                folder = Path(out_raw)
            folder.mkdir(parents=True, exist_ok=True)
            if sys.platform == "win32":
                os.startfile(str(folder))
            elif sys.platform == "darwin":
                subprocess.Popen(["open", str(folder)])
            else:
                subprocess.Popen(["xdg-open", str(folder)])

        def _browse_folder(self):
            init = self.entry_out_dir.get()
            if "system32" in init.lower() or "windows" in init.lower():
                init = str(get_safe_download_dir())
            sel = filedialog.askdirectory(initialdir=init)
            if sel:
                self.entry_out_dir.delete(0, "end")
                self.entry_out_dir.insert(0, sel)

        def _paste_clipboard(self):
            try:
                clip = self.clipboard_get().strip()
                if clip:
                    self.entry_query.delete(0, "end")
                    self.entry_query.insert(0, clip)
            except Exception:
                pass

        # -------------------------------------------------------------------
        # Búsqueda y Resultados (con Soporte Spotify y Añadir a Cola)
        # -------------------------------------------------------------------
        def _on_search_clicked(self):
            query = self.entry_query.get().strip()
            if not query:
                messagebox.showwarning("Atención", "Por favor escribe un término de búsqueda o pega un enlace.")
                return

            if is_spotify_url(query):
                self._handle_spotify_query(query)
                return

            parsed = parse_netease_url(query)
            if parsed:
                self._on_load_url_clicked()
                return

            self._set_status(f"Buscando canciones para: '{query}'...", pct=0.1)
            self._log(f"Buscando: {query}", "info")
            self.btn_search.configure(state="disabled")

            def task():
                try:
                    data = self.client.search(query, limit=40) or {}
                    hits = (data.get("result") or {}).get("songs") or []
                    songs = [Song(s) for s in hits]
                    self.after(0, lambda: self._display_results(songs, f"Resultados para '{query}' ({len(songs)})"))
                except Exception as e:
                    self._log(f"Error en búsqueda: {e}", "error")
                    self._set_status("Error en la búsqueda", pct=0)
                finally:
                    self.after(0, lambda: self.btn_search.configure(state="normal"))

            threading.Thread(target=task, daemon=True).start()

        def _handle_spotify_query(self, query: str):
            sp_parsed = parse_spotify_url(query)
            if not sp_parsed:
                messagebox.showwarning("Atención", "Enlace de Spotify no reconocido.")
                return

            sp_type, sp_id = sp_parsed
            self._set_status(f"Cargando {sp_type} de Spotify...", pct=0.1)
            self._log(f"Cargando Spotify {sp_type} ({sp_id})...", "info")
            self.btn_search.configure(state="disabled")
            self.btn_load_url.configure(state="disabled")

            def task():
                try:
                    def sp_cb(msg, level="info", pct=None):
                        self._log(msg, level)
                        if pct is not None:
                            self._set_status(msg, pct=pct)

                    name, songs = resolve_spotify_entity(self.client, sp_type, sp_id, progress_cb=sp_cb, cancel_event=self.cancel_event)
                    header_text = f"Spotify {sp_type.capitalize()}: {name} ({len(songs)} canciones)"
                    self.after(0, lambda: self._display_results(songs, header_text))
                except Exception as e:
                    self._log(f"Error cargando Spotify: {e}", "error")
                    self._set_status("Error cargando de Spotify", pct=0)
                finally:
                    self.after(0, lambda: self.btn_search.configure(state="normal"))
                    self.after(0, lambda: self.btn_load_url.configure(state="normal"))

            threading.Thread(target=task, daemon=True).start()

        def _on_load_url_clicked(self):
            query = self.entry_query.get().strip()
            if not query:
                messagebox.showwarning("Atención", "Por favor pega una URL o ID.")
                return

            if is_spotify_url(query):
                self._handle_spotify_query(query)
                return

            parsed = parse_netease_url(query)
            if not parsed:
                self._on_search_clicked()
                return

            kind, target_id = parsed
            self._set_status(f"Cargando {kind} con ID {target_id}...", pct=0.2)
            self._log(f"Cargando {kind} (ID: {target_id})...", "info")
            self.btn_load_url.configure(state="disabled")

            def task():
                try:
                    songs, title = [], kind
                    if kind == "song":
                        songs = collect_from_ids(self.client, [target_id])
                        title = f"Canción: {songs[0].display if songs else target_id}"
                    elif kind == "album":
                        songs, title = collect_album(self.client, target_id)
                    elif kind == "playlist":
                        songs, title = collect_playlist(self.client, target_id)
                    elif kind == "artist":
                        songs, title = collect_artist(self.client, target_id)

                    self.after(0, lambda: self._display_results(songs, f"{title} ({len(songs)} canciones)"))
                except Exception as e:
                    self._log(f"Error al cargar {kind}: {e}", "error")
                    self._set_status(f"Error al cargar {kind}", pct=0)
                finally:
                    self.after(0, lambda: self.btn_load_url.configure(state="normal"))

            threading.Thread(target=task, daemon=True).start()

        def _display_results(self, songs: List[Song], header_text: str):
            for child in self.scroll_results.winfo_children():
                child.destroy()

            self.results_songs = songs
            self.song_check_vars = []

            self.lbl_results_count.configure(text=header_text)
            self._set_status(f"Se encontraron {len(songs)} canciones.", pct=1.0)
            self._log(f"Cargadas {len(songs)} canciones en la lista.", "ok")

            if not songs:
                lbl_empty = ctk.CTkLabel(self.scroll_results, text="No se encontraron canciones.", font=ctk.CTkFont(size=13))
                lbl_empty.pack(pady=20)
                self._update_selected_count()
                return

            for i, song in enumerate(songs, 1):
                row_frame = ctk.CTkFrame(self.scroll_results, fg_color=("gray90", "gray20"), height=38)
                row_frame.pack(fill="x", expand=True, padx=2, pady=2)
                row_frame.grid_columnconfigure(2, weight=1)

                var = ctk.BooleanVar(value=True)
                self.song_check_vars.append(var)

                chk = ctk.CTkCheckBox(row_frame, text="", variable=var, width=24, command=self._update_selected_count)
                chk.grid(row=0, column=0, padx=(8, 4), pady=6)

                lbl_idx = ctk.CTkLabel(row_frame, text=f"{i:02d}.", width=28, font=ctk.CTkFont(size=12, weight="bold"))
                lbl_idx.grid(row=0, column=1, padx=(0, 6), pady=6)

                song_info = f"{song.title}  —  {song.artist}"
                lbl_title = ctk.CTkLabel(row_frame, text=song_info, anchor="w", font=ctk.CTkFont(size=12))
                lbl_title.grid(row=0, column=2, sticky="w", padx=4, pady=6)

                # Badge FLAC / VIP
                if song.has_flac:
                    flac_badge_text = f"✨ FLAC {song.flac_size_mb}MB" if song.flac_size_mb > 0 else "✨ FLAC"
                    lbl_flac = ctk.CTkLabel(row_frame, text=flac_badge_text, width=85, text_color=("#8e44ad", "#9b59b6"), font=ctk.CTkFont(size=11, weight="bold"))
                else:
                    lbl_flac = ctk.CTkLabel(row_frame, text="320K MP3", width=70, text_color=("gray40", "gray70"), font=ctk.CTkFont(size=11))
                lbl_flac.grid(row=0, column=3, padx=4, pady=6)

                fee_text = "🔒 VIP" if song.fee in (1, 4, 8) else "🆓 Free"
                lbl_fee = ctk.CTkLabel(row_frame, text=fee_text, width=45, text_color=("#e67e22" if song.fee in (1, 4, 8) else "#27ae60"), font=ctk.CTkFont(size=11, weight="bold"))
                lbl_fee.grid(row=0, column=4, padx=4, pady=6)

                lbl_album = ctk.CTkLabel(row_frame, text=song.album[:20], text_color=("gray40", "gray70"), anchor="w", font=ctk.CTkFont(size=11))
                lbl_album.grid(row=0, column=5, sticky="w", padx=8, pady=6)

                lbl_dur = ctk.CTkLabel(row_frame, text=song.duration_str, width=45, font=ctk.CTkFont(size=11))
                lbl_dur.grid(row=0, column=6, padx=(4, 6), pady=6)

                # Botón de añadir a la Cola persistente
                in_cart = song.id in self.cart_song_ids
                btn_cart = ctk.CTkButton(
                    row_frame,
                    text="✔ En Cola" if in_cart else "➕ Cola",
                    width=68,
                    height=24,
                    fg_color="#27ae60" if in_cart else "#8e44ad",
                    hover_color="#2ecc71" if in_cart else "#9b59b6",
                    font=ctk.CTkFont(size=10, weight="bold")
                )
                btn_cart.configure(command=lambda s=song, b=btn_cart: self._toggle_cart_song(s, b))
                btn_cart.grid(row=0, column=7, padx=(2, 6), pady=6)

            self._update_selected_count()

        def _update_selected_count(self):
            selected_count = sum(1 for v in self.song_check_vars if v.get())
            self.btn_download_selected.configure(text=f"⬇️ Descargar Seleccionadas ({selected_count})")
            self.btn_add_to_cart_sel.configure(text=f"➕ Añadir Seleccionadas a la Cola ({selected_count})")

        def _select_all_results(self):
            for v in self.song_check_vars:
                v.set(True)
            self._update_selected_count()

        def _deselect_all_results(self):
            for v in self.song_check_vars:
                v.set(False)
            self._update_selected_count()

        def _clear_results(self):
            for child in self.scroll_results.winfo_children():
                child.destroy()
            self.results_songs = []
            self.song_check_vars = []
            self.lbl_results_count.configure(text="Sin resultados aún.")
            self._update_selected_count()

        # -------------------------------------------------------------------
        # Gestión de la Cola / Carrito de Descarga Persistente
        # -------------------------------------------------------------------
        def _update_cart_ui(self):
            count = len(self.cart_songs)
            # Actualizar botón del header
            self.btn_header_cart.configure(text=f"📥 Mi Cola ({count})")
            # Actualizar botón de la barra de búsqueda
            self.btn_search_go_cart.configure(text=f"📥 Ver Mi Cola ({count})")
            # Actualizar texto del segmented button de la pestaña si está disponible
            try:
                btn = self.tabview._segmented_button._buttons_dict.get("📥 Cola de Descarga")
                if btn:
                    btn.configure(text=f"📥 Cola ({count})")
            except Exception:
                pass

            self._render_cart_songs()

        def _toggle_cart_song(self, song: Song, btn: Optional[ctk.CTkButton] = None):
            if song.id in self.cart_song_ids:
                self._remove_from_cart(song.id)
                if btn:
                    btn.configure(text="➕ Cola", fg_color="#8e44ad", hover_color="#9b59b6")
            else:
                self.cart_songs.append(song)
                self.cart_song_ids.add(song.id)
                self._log(f"Añadida a la cola: {song.display}", "ok")
                if btn:
                    btn.configure(text="✔ En Cola", fg_color="#27ae60", hover_color="#2ecc71")
                self._update_cart_ui()

        def _add_selected_to_cart(self):
            selected = [song for song, var in zip(self.results_songs, self.song_check_vars) if var.get()]
            if not selected:
                messagebox.showwarning("Atención", "No has seleccionado ninguna canción para añadir a la cola.")
                return

            added = 0
            for s in selected:
                if s.id not in self.cart_song_ids:
                    self.cart_songs.append(s)
                    self.cart_song_ids.add(s.id)
                    added += 1

            self._update_cart_ui()
            # Refrescar botones de la lista actual de resultados
            self._display_results(self.results_songs, self.lbl_results_count.cget("text"))
            self._log(f"Se añadieron {added} canciones nuevas a la cola de descarga. Total en cola: {len(self.cart_songs)}", "ok")
            self._set_status(f"{added} canciones añadidas a la Cola de Descarga.", pct=1.0)

        def _remove_from_cart(self, song_id: int):
            self.cart_songs = [s for s in self.cart_songs if s.id != song_id]
            self.cart_song_ids.discard(song_id)
            self._update_cart_ui()

        def _clear_cart(self):
            if not self.cart_songs:
                return
            self.cart_songs = []
            self.cart_song_ids.clear()
            self._update_cart_ui()
            self._log("Se vació la cola de descarga.", "info")

        def _render_cart_songs(self):
            for child in self.scroll_cart.winfo_children():
                child.destroy()

            count = len(self.cart_songs)
            if count == 0:
                self.lbl_cart_header.configure(text="📥 Tu Cola de Descarga está vacía (0 canciones).")
                self.btn_download_cart.configure(text="⬇️ Descargar Toda la Cola en FLAC (0 canciones)", state="disabled")
                
                empty_frame = ctk.CTkFrame(self.scroll_cart, fg_color="transparent")
                empty_frame.pack(pady=40, fill="both", expand=True)
                
                lbl_icon = ctk.CTkLabel(empty_frame, text="🛒", font=ctk.CTkFont(size=42))
                lbl_icon.pack(pady=(10, 4))
                
                lbl_empty = ctk.CTkLabel(
                    empty_frame,
                    text="Tu Cola de Descarga está vacía.",
                    font=ctk.CTkFont(size=15, weight="bold")
                )
                lbl_empty.pack(pady=(0, 4))
                
                lbl_tip = ctk.CTkLabel(
                    empty_frame,
                    text="Busca canciones en '🔍 Búsqueda y Enlaces' o importa listas de Spotify en '📋 Listas y Lotes',\ny pulsa '➕ Añadir a la Cola' para ir acumulando todas las canciones que quieras descargar juntas.",
                    font=ctk.CTkFont(size=12),
                    text_color=("gray40", "gray70"),
                    justify="center"
                )
                lbl_tip.pack(pady=(0, 15))
                
                btn_go_search = ctk.CTkButton(
                    empty_frame,
                    text="🔍 Ir al Buscador de Canciones",
                    width=210,
                    height=36,
                    command=lambda: self.tabview.set("🔍 Búsqueda y Enlaces")
                )
                btn_go_search.pack()
                return

            self.lbl_cart_header.configure(text=f"📥 Cola de Descarga: {count} canciones acumuladas listas para descargar en FLAC.")
            self.btn_download_cart.configure(text=f"⬇️ Descargar Toda la Cola en FLAC ({count} canciones)", state="normal")

            for i, song in enumerate(self.cart_songs, 1):
                row = ctk.CTkFrame(self.scroll_cart, fg_color=("gray90", "gray20"), height=38)
                row.pack(fill="x", expand=True, padx=2, pady=2)
                row.grid_columnconfigure(2, weight=1)

                lbl_idx = ctk.CTkLabel(row, text=f"{i:02d}.", width=30, font=ctk.CTkFont(size=12, weight="bold"))
                lbl_idx.grid(row=0, column=0, padx=(8, 4), pady=6)

                song_info = f"{song.title}  —  {song.artist}"
                lbl_title = ctk.CTkLabel(row, text=song_info, anchor="w", font=ctk.CTkFont(size=12))
                lbl_title.grid(row=0, column=2, sticky="w", padx=4, pady=6)

                # Badge FLAC
                if song.has_flac:
                    flac_badge_text = f"✨ FLAC {song.flac_size_mb}MB" if song.flac_size_mb > 0 else "✨ FLAC"
                    lbl_flac = ctk.CTkLabel(row, text=flac_badge_text, width=85, text_color=("#8e44ad", "#9b59b6"), font=ctk.CTkFont(size=11, weight="bold"))
                else:
                    lbl_flac = ctk.CTkLabel(row, text="320K MP3", width=70, text_color=("gray40", "gray70"), font=ctk.CTkFont(size=11))
                lbl_flac.grid(row=0, column=3, padx=4, pady=6)

                lbl_album = ctk.CTkLabel(row, text=song.album[:22], text_color=("gray40", "gray70"), anchor="w", font=ctk.CTkFont(size=11))
                lbl_album.grid(row=0, column=4, sticky="w", padx=8, pady=6)

                lbl_dur = ctk.CTkLabel(row, text=song.duration_str, width=45, font=ctk.CTkFont(size=11))
                lbl_dur.grid(row=0, column=5, padx=(4, 6), pady=6)

                btn_remove = ctk.CTkButton(
                    row,
                    text="❌ Quitar",
                    width=70,
                    height=24,
                    fg_color="#c0392b",
                    hover_color="#e74c3c",
                    font=ctk.CTkFont(size=10, weight="bold"),
                    command=lambda sid=song.id: self._remove_from_cart(sid)
                )
                btn_remove.grid(row=0, column=6, padx=(2, 8), pady=6)

        def _download_cart_songs(self):
            if not self.cart_songs:
                messagebox.showwarning("Atención", "No hay canciones en la cola de descarga.")
                return
            self._start_download_process(list(self.cart_songs), m3u_name="cola_descargas")

        # -------------------------------------------------------------------
        # Vista Previa y Procesamiento de Listas y Lotes
        # -------------------------------------------------------------------
        def _analyze_batch_preview(self):
            lines = [l.strip() for l in self.txt_batch.get("1.0", "end").splitlines() if l.strip()]
            if not lines:
                messagebox.showwarning("Atención", "Escribe o pega al menos un enlace (Spotify/NetEase) o nombre de canción.")
                return

            self._sync_opts()
            self._set_status("Analizando lista de canciones...", pct=0.05)
            self._log(f"Iniciando análisis y previsualización de {len(lines)} elementos...", "info")
            self.btn_preview_batch.configure(state="disabled")
            self.btn_start_batch.configure(state="disabled")

            def task():
                try:
                    def batch_cb(msg, level="info", pct=None):
                        self._log(msg, level)
                        if pct is not None:
                            self._set_status(msg, pct=pct)

                    batch_title, songs = resolve_mixed_batch_to_songs(
                        self.client, lines, progress_cb=batch_cb, cancel_event=self.cancel_event
                    )
                    if not songs:
                        self._log("No se pudieron resolver canciones del lote.", "error")
                        self._set_status("No se encontraron canciones válidas.", pct=0)
                        self.after(0, lambda: messagebox.showwarning("Sin resultados", "No se encontraron canciones válidas en el lote."))
                        return

                    self._log(f"Análisis completado: {len(songs)} canciones listas para previsualizar.", "ok")
                    self.after(0, lambda: self._show_batch_preview(songs, batch_title))
                except Exception as e:
                    self._log(f"Error analizando lote: {e}", "error")
                    self._set_status("Error en el análisis del lote.", pct=0)
                finally:
                    self.after(0, lambda: self.btn_preview_batch.configure(state="normal"))
                    self.after(0, lambda: self.btn_start_batch.configure(state="normal"))

            threading.Thread(target=task, daemon=True).start()

        def _show_batch_preview(self, songs: List[Song], batch_name: str):
            self.batch_preview_songs = songs
            self.batch_preview_name = batch_name
            self.batch_preview_check_vars = []

            for child in self.scroll_batch_preview.winfo_children():
                child.destroy()

            self.lbl_batch_preview_title.configure(
                text=f"📋 Vista Previa de Lote: '{batch_name}' — {len(songs)} canciones encontradas"
            )

            for i, song in enumerate(songs, 1):
                row = ctk.CTkFrame(self.scroll_batch_preview, fg_color=("gray90", "gray20"), height=38)
                row.pack(fill="x", expand=True, padx=2, pady=2)
                row.grid_columnconfigure(2, weight=1)

                var = ctk.BooleanVar(value=True)
                self.batch_preview_check_vars.append(var)

                chk = ctk.CTkCheckBox(row, text="", variable=var, width=24, command=self._update_preview_buttons)
                chk.grid(row=0, column=0, padx=(8, 4), pady=6)

                lbl_idx = ctk.CTkLabel(row, text=f"{i:02d}.", width=28, font=ctk.CTkFont(size=12, weight="bold"))
                lbl_idx.grid(row=0, column=1, padx=(0, 6), pady=6)

                song_info = f"{song.title}  —  {song.artist}"
                lbl_title = ctk.CTkLabel(row, text=song_info, anchor="w", font=ctk.CTkFont(size=12))
                lbl_title.grid(row=0, column=2, sticky="w", padx=4, pady=6)

                if song.has_flac:
                    flac_badge_text = f"✨ FLAC {song.flac_size_mb}MB" if song.flac_size_mb > 0 else "✨ FLAC"
                    lbl_flac = ctk.CTkLabel(row, text=flac_badge_text, width=85, text_color=("#8e44ad", "#9b59b6"), font=ctk.CTkFont(size=11, weight="bold"))
                else:
                    lbl_flac = ctk.CTkLabel(row, text="320K MP3", width=70, text_color=("gray40", "gray70"), font=ctk.CTkFont(size=11))
                lbl_flac.grid(row=0, column=3, padx=4, pady=6)

                lbl_album = ctk.CTkLabel(row, text=song.album[:22], text_color=("gray40", "gray70"), anchor="w", font=ctk.CTkFont(size=11))
                lbl_album.grid(row=0, column=4, sticky="w", padx=8, pady=6)

                lbl_dur = ctk.CTkLabel(row, text=song.duration_str, width=45, font=ctk.CTkFont(size=11))
                lbl_dur.grid(row=0, column=5, padx=(4, 8), pady=6)

            self._update_preview_buttons()

            # Cambiar a la vista previa
            self.frame_batch_input.grid_remove()
            self.frame_batch_preview.grid(row=0, column=0, sticky="nsew")

        def _update_preview_buttons(self):
            sel_count = sum(1 for v in self.batch_preview_check_vars if v.get())
            self.btn_preview_add_cart.configure(text=f"➕ Añadir Seleccionadas a la Cola ({sel_count})")
            self.btn_preview_download.configure(text=f"⬇️ Descargar Seleccionadas en FLAC ({sel_count})")

        def _select_all_batch_preview(self):
            for v in self.batch_preview_check_vars:
                v.set(True)
            self._update_preview_buttons()

        def _deselect_all_batch_preview(self):
            for v in self.batch_preview_check_vars:
                v.set(False)
            self._update_preview_buttons()

        def _back_to_batch_input(self):
            self.frame_batch_preview.grid_remove()
            self.frame_batch_input.grid(row=0, column=0, sticky="nsew")

        def _add_preview_to_cart(self):
            selected = [song for song, var in zip(self.batch_preview_songs, self.batch_preview_check_vars) if var.get()]
            if not selected:
                messagebox.showwarning("Atención", "No has seleccionado ninguna canción de la lista.")
                return

            added = 0
            for s in selected:
                if s.id not in self.cart_song_ids:
                    self.cart_songs.append(s)
                    self.cart_song_ids.add(s.id)
                    added += 1

            self._update_cart_ui()
            self._log(f"Se añadieron {added} canciones de la lista a la Cola de Descarga.", "ok")
            messagebox.showinfo("Cola Actualizada", f"Se han añadido {added} canciones a tu Cola de Descarga.\n\nTotal en cola: {len(self.cart_songs)} canciones.")
            self.tabview.set("📥 Cola de Descarga")

        def _download_preview_selected(self):
            selected = [song for song, var in zip(self.batch_preview_songs, self.batch_preview_check_vars) if var.get()]
            if not selected:
                messagebox.showwarning("Atención", "No has seleccionado ninguna canción de la lista.")
                return
            self._start_download_process(selected, m3u_name=self.batch_preview_name or "lote_descargas")

        def _start_batch_download(self):
            lines = [l.strip() for l in self.txt_batch.get("1.0", "end").splitlines() if l.strip()]
            if not lines:
                messagebox.showwarning("Atención", "Escribe o pega al menos una URL o nombre de canción en el cuadro de lote.")
                return

            self._sync_opts()
            self._set_status(f"Analizando {len(lines)} elementos del lote...", pct=0.05)
            self._log(f"Iniciando procesamiento directo de lote ({len(lines)} elementos)...", "info")

            def resolve_and_download():
                def batch_cb(msg, level="info", pct=None):
                    self._log(msg, level)
                    if pct is not None:
                        self._set_status(msg, pct=pct)

                name, unique_songs = resolve_mixed_batch_to_songs(
                    self.client, lines, progress_cb=batch_cb, cancel_event=self.cancel_event
                )

                if not unique_songs:
                    self._log("No se pudieron resolver canciones del lote.", "error")
                    self._set_status("Lote sin canciones válidas.", pct=0)
                    return

                self._log(f"Lote resuelto: {len(unique_songs)} canciones únicas a descargar en FLAC.", "info")
                self.after(0, lambda: self._start_download_process(unique_songs, m3u_name=name or "lote_descargas"))

            threading.Thread(target=resolve_and_download, daemon=True).start()

        def _import_batch_file(self):
            file_path = filedialog.askopenfilename(
                title="Seleccionar archivo con enlaces o canciones",
                filetypes=[("Archivos de texto", "*.txt"), ("Todos los archivos", "*.*")]
            )
            if file_path:
                try:
                    content = Path(file_path).read_text(encoding="utf-8")
                    self.txt_batch.delete("1.0", "end")
                    self.txt_batch.insert("1.0", content)
                    self._log(f"Cargado archivo de lote: {Path(file_path).name}", "info")
                except Exception as e:
                    messagebox.showerror("Error", f"No se pudo leer el archivo: {e}")

        # -------------------------------------------------------------------
        # Ejecución y Control de Descargas
        # -------------------------------------------------------------------
        def _download_selected_songs(self):
            to_download = [song for song, var in zip(self.results_songs, self.song_check_vars) if var.get()]
            if not to_download:
                messagebox.showwarning("Atención", "No has seleccionado ninguna canción para descargar.")
                return
            self._start_download_process(to_download)

        def _download_all_results(self):
            if not self.results_songs:
                messagebox.showwarning("Atención", "No hay canciones en la lista.")
                return
            self._start_download_process(self.results_songs)

        def _start_download_process(self, songs: List[Song], m3u_name="descargas"):
            if self.is_downloading:
                messagebox.showwarning("Atención", "Ya hay una descarga en curso. Espera o cancela la actual.")
                return

            self._sync_opts()
            out_raw = self.entry_out_dir.get().strip()
            if not out_raw or "system32" in out_raw.lower() or "windows" in out_raw.lower():
                out_folder = get_safe_download_dir()
                self.entry_out_dir.delete(0, "end")
                self.entry_out_dir.insert(0, str(out_folder))
            else:
                out_folder = Path(out_raw)

            try:
                out_folder.mkdir(parents=True, exist_ok=True)
            except Exception:
                out_folder = get_safe_download_dir()
                out_folder.mkdir(parents=True, exist_ok=True)
                self.entry_out_dir.delete(0, "end")
                self.entry_out_dir.insert(0, str(out_folder))

            self.is_downloading = True
            self.cancel_event.clear()
            self.btn_cancel.configure(state="normal")
            self.btn_download_selected.configure(state="disabled")
            self.btn_download_all.configure(state="disabled")
            self.btn_start_batch.configure(state="disabled")
            self.btn_preview_batch.configure(state="disabled")
            self.btn_download_cart.configure(state="disabled")

            total = len(songs)
            self._set_status(f"Iniciando descarga de {total} canciones...", pct=0)
            self._log(f"Iniciando descarga de {total} canciones en '{out_folder}' | Calidad objetivo: {OPTS.quality.upper()}", "info")

            def progress_callback(msg, level="info", pct=None):
                self._log(msg, level)
                if pct is not None:
                    self._set_status(f"Descargando {int(pct*total)}/{total} canciones ({int(pct*100)}%)...", pct=pct)

            def worker():
                start_time = time.time()
                try:
                    results, failures = run_downloads(
                        self.client,
                        songs,
                        out_folder,
                        m3u_name=m3u_name,
                        progress_cb=progress_callback,
                        cancel_event=self.cancel_event
                    )
                    elapsed = time.time() - start_time
                    success_count = len(results) - failures

                    if self.cancel_event.is_set():
                        self._log("Descarga detenida por el usuario.", "warn")
                        self._set_status("Descarga cancelada.", pct=0)
                    else:
                        self._log(f"Proceso finalizado en {elapsed:.1f}s: {success_count} correctas, {failures} fallidas sobre {len(results)}.", "ok" if failures == 0 else "warn")
                        self._set_status(f"Completado: {success_count}/{len(results)} canciones descargadas.", pct=1.0)
                        self.after(0, lambda: messagebox.showinfo(
                            "Descargas Finalizadas",
                            f"Descarga finalizada:\n\n• Correctas (completas): {success_count}\n• Con fallas: {failures}\n• Total: {len(results)}\n\nCarpeta: {out_folder}"
                        ))
                except Exception as exc:
                    self._log(f"Error inesperado durante la descarga: {exc}", "error")
                    self._set_status("Error en la descarga.", pct=0)
                finally:
                    self.is_downloading = False
                    self.after(0, self._reset_download_ui)

            self.download_thread = threading.Thread(target=worker, daemon=True)
            self.download_thread.start()

        def _cancel_download(self):
            if self.is_downloading:
                self.cancel_event.set()
                self._log("Cancelando descargas, esperando hilos activos...", "warn")
                self._set_status("Cancelando...", pct=None)

        def _reset_download_ui(self):
            self.btn_cancel.configure(state="disabled")
            self.btn_download_selected.configure(state="normal")
            self.btn_download_all.configure(state="normal")
            self.btn_start_batch.configure(state="normal")
            self.btn_preview_batch.configure(state="normal")
            self.btn_download_cart.configure(state="normal" if self.cart_songs else "disabled")

    app = App()
    app.mainloop()


# ---------------------------------------------------------------------------
# CLI Parser y ejecución por consola
# ---------------------------------------------------------------------------
def build_parser():
    p = argparse.ArgumentParser(
        prog="netease_downloader",
        description="Descarga música en la mayor calidad posible (FLAC / Lossless / 320k) desde Spotify o NetEase.",
        epilog="Ejemplos:\n"
               "  python netease_downloader.py (Abre la interfaz gráfica con Cola y Previsualización)\n"
               "  python netease_downloader.py --url https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M\n"
               "  python netease_downloader.py --search \"Queen Bohemian Rhapsody\"\n"
               "  python netease_downloader.py --list mis_canciones.txt",
        formatter_class=argparse.RawDescriptionHelpFormatter)
    g = p.add_mutually_exclusive_group(required=False)
    g.add_argument("--search", metavar="QUERY", help="buscar y elegir una canción")
    g.add_argument("--id", metavar="IDS", help="ids de canción (separados por coma)")
    g.add_argument("--url", metavar="URL", help="URL de Spotify o NetEase (canción, álbum o lista)")
    g.add_argument("--playlist", metavar="ID", help="id o URL de lista")
    g.add_argument("--album", metavar="ID", help="id o URL de álbum")
    g.add_argument("--artist", metavar="ID", help="id de artista")
    g.add_argument("--list", metavar="FILE", dest="listfile",
                   help="fichero de texto con enlaces de Spotify, NetEase o nombres de canciones")
    p.add_argument("-o", "--out", default="downloads", help="carpeta de salida")
    p.add_argument("-q", "--quality", default="flac",
                   choices=["auto", "flac", "lossless", "hires", "jymaster", "exhigh",
                            "higher", "standard"],
                   help="calidad solicitada (flac/hires entrega FLAC)")
    p.add_argument("--flac-only", action="store_true",
                   help="no guardar si no llega FLAC")
    p.add_argument("--no-tags", action="store_true")
    p.add_argument("--no-cover", action="store_true")
    p.add_argument("--no-lyrics", action="store_true")
    p.add_argument("--overwrite", action="store_true")
    p.add_argument("--workers", type=int, default=1, metavar="N")
    p.add_argument("--delay", type=float, default=0.0, metavar="SEC",
                   help="pausa entre descargas (evitar baneo)")
    p.add_argument("--cookie", default="",
                   help="cookie de cuenta (MUSIC_U=...) para FLAC/derechos; "
                        "o usar variable NCM_COOKIE")
    p.add_argument("-v", "--verbose", action="store_true")
    p.add_argument("--gui", action="store_true", help="abrir la interfaz gráfica")
    return p


def guess_id(kind_target, text):
    parsed = parse_netease_url(text)
    return parsed[1] if parsed else text


def main(argv=None):
    if argv is None and len(sys.argv) == 1:
        launch_gui()
        return 0

    args = build_parser().parse_args(argv)

    if args.gui:
        launch_gui()
        return 0

    has_target = any([args.search, args.id, args.url, args.playlist, args.album, args.artist, args.listfile])
    if not has_target:
        launch_gui()
        return 0

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(levelname)s %(message)s")
    cookie = args.cookie or os.environ.get("NCM_COOKIE", "")
    OPTS.quality = args.quality
    OPTS.tags = not args.no_tags
    OPTS.cover = not args.no_cover
    OPTS.lyrics = not args.no_lyrics
    OPTS.flac_only = args.flac_only
    OPTS.overwrite = args.overwrite
    OPTS.workers = args.workers
    OPTS.delay = args.delay

    client = NeteaseClient(cookie=cookie)
    songs, m3u_name, subdir = [], None, None

    if args.id:
        songs = collect_from_ids(client, [x for x in args.id.split(",") if x])
    elif args.url:
        if is_spotify_url(args.url):
            sp_type, sp_id = parse_spotify_url(args.url)
            log.info("Extrayendo canciones de Spotify (%s: %s)...", sp_type, sp_id)
            subdir, songs = resolve_spotify_entity(client, sp_type, sp_id)
            m3u_name = subdir
        else:
            ty, sid = parse_netease_url(args.url)
            if ty == "song":
                songs = collect_from_ids(client, [sid])
            elif ty == "album":
                songs, subdir = collect_album(client, sid)
            elif ty == "playlist":
                songs, subdir = collect_playlist(client, sid)
            elif ty == "artist":
                songs, subdir = collect_artist(client, sid)
            m3u_name = subdir
    elif args.playlist:
        songs, subdir = collect_playlist(client, guess_id("playlist", args.playlist))
        m3u_name = subdir
    elif args.album:
        songs, subdir = collect_album(client, guess_id("album", args.album))
        m3u_name = subdir
    elif args.artist:
        songs, subdir = collect_artist(client, guess_id("artist", args.artist))
        m3u_name = subdir
    elif args.search:
        data = client.search(args.search) or {}
        hits = (data.get("result") or {}).get("songs") or []
        if not hits:
            log.error("sin resultados para: %s", args.search)
            return 1
        for i, s in enumerate(hits[:20], 1):
            print("  %2d. %s  [%ds]" % (i, Song(s).display, (s.get("dt") or 0) // 1000))
        choice = input("Elegir nº (vacío = 1): ").strip()
        idx = int(choice) if choice.isdigit() and 0 < int(choice) <= len(hits) else 1
        songs = [Song(hits[idx - 1])]
    elif args.listfile:
        lines = [l.strip() for l in open(args.listfile, encoding="utf-8") if l.strip()]
        subdir, songs = resolve_mixed_batch_to_songs(client, lines)
        m3u_name = "lote_importado"

    if not songs:
        log.error("no se obtuvieron canciones.")
        return 1

    if not args.out or args.out == "downloads" or "system32" in str(args.out).lower():
        base_dir = get_safe_download_dir()
    else:
        base_dir = Path(args.out)

    if subdir:
        out_dir = base_dir / _sanitize(str(subdir)).strip("_")
    else:
        out_dir = base_dir

    log.info("Carpeta destino: %s  |  canciones: %d", out_dir, len(songs))
    results, failures = run_downloads(client, songs, out_dir, m3u_name=m3u_name)
    log.info("Hecho: %d correctas, %d con problema sobre %d.",
             len(results) - failures, failures, len(results))
    return 0 if failures == 0 else 2


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