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".
- Descarga el repositorio y extrae el contenido en una carpeta dedicada.
- 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.txtdentro 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 ffmpego 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())