From 30be62af783b2fe0db49efe6dcb27687ff526dcf Mon Sep 17 00:00:00 2001 From: luzadev Date: Tue, 9 Jun 2026 11:33:28 +0200 Subject: [PATCH] v1.7.2: brani Spotify scaricabili senza API keys + fix download.html Spotify: per i singoli brani ora basta incollare il link, l'app estrae name+artist dai meta og:title/og:description pubblici e risolve via YouTube. Album/playlist continuano a richiedere le credenziali (gratis, 2 min). Landing /download: bottoni Mac/Windows ora navigano nella stessa tab via JS handler + feedback "avvio in corso" invece del fragile target="_blank". --- api/bridge.py | 36 +++++++++++++------ core/config.py | 2 +- core/spotify_client.py | 80 ++++++++++++++++++++++++++++++++++++++++++ landing/download.html | 69 ++++++++++++++++++++++++++++++------ 4 files changed, 166 insertions(+), 21 deletions(-) diff --git a/api/bridge.py b/api/bridge.py index aaa512d..4c84b40 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -20,7 +20,12 @@ from core.downloader import ( reset_stop as reset_download_stop, is_stopped as download_is_stopped, ) -from core.spotify_client import get_access_token, resolve_spotify_url +from core.spotify_client import ( + get_access_token, + resolve_spotify_url, + resolve_spotify_track_no_auth, + SpotifyAuthRequired, +) from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS from core.recorder import ( list_input_devices, @@ -590,14 +595,11 @@ class Api: view = "download" token = None + client_id = cfg.get("client_id", "") + client_secret = cfg.get("client_secret", "") + has_credentials = bool(client_id and client_secret) has_spotify = any("spotify.com" in u for u in urls) - if has_spotify: - client_id = cfg.get("client_id", "") - client_secret = cfg.get("client_secret", "") - if not client_id or not client_secret: - self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.") - self._emit("download:done", {"ok": False}) - return + if has_spotify and has_credentials: self._log(view, "[INFO] Autenticazione Spotify...") try: token = get_access_token(client_id, client_secret) @@ -605,6 +607,13 @@ class Api: self._log(view, f"[ERRORE] Autenticazione fallita: {e}") self._emit("download:done", {"ok": False}) return + elif has_spotify: + self._log( + view, + "[INFO] Spotify API non configurate: i singoli brani " + "verranno comunque scaricati via metadata pubblico. " + "Per album/playlist servono le credenziali (gratuite, 2 min)." + ) for url_idx, url in enumerate(urls): if download_is_stopped(): @@ -664,12 +673,19 @@ class Api: if is_spotify: self._log(view, "[INFO] Recupero informazioni da Spotify...") try: - label, name, tracks = resolve_spotify_url(token, url) + if token: + label, name, tracks = resolve_spotify_url(token, url) + else: + # Senza API keys: fallback solo per singoli brani. + label, name, tracks = resolve_spotify_track_no_auth(url) + except SpotifyAuthRequired as e: + self._log(view, f"[ERRORE] {e}") + continue except Exception as e: self._log(view, f"[ERRORE] {e}") continue self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)") - dest_dir = os.path.join(output_dir, name) + dest_dir = os.path.join(output_dir, name) if len(tracks) > 1 else output_dir download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb) else: self._log(view, f"[INFO] Download diretto via yt-dlp: {url}") diff --git a/core/config.py b/core/config.py index 4f0aef8..40e2185 100644 --- a/core/config.py +++ b/core/config.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -VERSION = "v1.7.1" +VERSION = "v1.7.2" APP_NAME = "MusicTools" diff --git a/core/spotify_client.py b/core/spotify_client.py index d662db3..2085e55 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -175,3 +175,83 @@ def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]: else: name, tracks = get_playlist_info(token, item_id) return "Playlist", name, tracks + + +# --------------------------------------------------------------------------- +# Fallback senza credenziali (solo per singoli brani) +# --------------------------------------------------------------------------- + +class SpotifyAuthRequired(Exception): + """Sollevata quando un URL Spotify richiede le API keys (album/playlist).""" + + +_OG_TITLE_RE = re.compile( + r'", +} + + +def _decode_entities(s: str) -> str: + for k, v in _HTML_ENTITIES.items(): + s = s.replace(k, v) + return s + + +def resolve_spotify_track_no_auth(url: str) -> tuple[str, str, list[dict]]: + """Estrae name/artist da un URL Spotify SENZA credenziali API. + + Funziona solo per brani singoli (open.spotify.com/track/): legge + i meta tag og:title e og:description della pagina pubblica. + + Per album/playlist non c'e' modo affidabile senza JS rendering -> alza + SpotifyAuthRequired. + + Ritorna ("Brano", name, [{name, artist}]). + """ + kind, _ = detect_url_type(url) + if kind != "track": + raise SpotifyAuthRequired( + f"I link a {kind} richiedono le credenziali Spotify (gratuite). " + "Vai in Impostazioni -> Spotify API per configurarle in 2 minuti." + ) + + try: + resp = requests.get( + url, + headers={"User-Agent": "Mozilla/5.0 (MusicTools)"}, + timeout=15, + ) + resp.raise_for_status() + except requests.RequestException as e: + raise ValueError(f"Impossibile leggere il brano da Spotify: {e}") from e + + html = resp.text + title_m = _OG_TITLE_RE.search(html) + desc_m = _OG_DESC_RE.search(html) + if not title_m: + raise ValueError("Risposta inattesa da Spotify (meta og:title mancante).") + + name = _decode_entities(title_m.group(1)).strip() + artist = "" + if desc_m: + # Formato osservato: "Artist Name · Song Title · Song · YYYY" + # oppure con piu' artisti separati da ", ". + parts = [_decode_entities(p).strip() for p in desc_m.group(1).split("·")] + if parts: + artist = parts[0] + + if not artist: + raise ValueError( + "Impossibile estrarre l'artista dal brano Spotify senza API keys. " + "Configura le credenziali in Impostazioni." + ) + + track = {"name": name, "artist": artist} + return "Brano", name, [track] diff --git a/landing/download.html b/landing/download.html index 86796b0..03b330d 100644 --- a/landing/download.html +++ b/landing/download.html @@ -90,6 +90,24 @@ .dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; } .dl-support a { color: var(--accent); } + + .dl-status { + max-width: 460px; + margin: -16px auto 24px; + padding: 12px 18px; + text-align: center; + background: rgba(29, 185, 84, 0.08); + border: 1px solid rgba(29, 185, 84, 0.25); + color: var(--accent); + border-radius: 12px; + font-size: 14px; + font-weight: 600; + } + .dl-status.err { + background: rgba(239, 68, 68, 0.08); + border-color: rgba(239, 68, 68, 0.3); + color: #f87171; + } @@ -137,7 +155,7 @@
🍎

macOS

macOS 11 Big Sur o successivi · Apple Silicon e Intel

- + Scarica per Mac @@ -146,12 +164,14 @@
🪟

Windows

Windows 10 e 11 · 64-bit

- + Scarica per Windows + +

Installazione macOS

    @@ -184,19 +204,47 @@ return url.searchParams.get(name) || ""; } - function setDownloadLinks(email, key) { - const q = "key=" + encodeURIComponent(key) + "&email=" + encodeURIComponent(email); - document.getElementById("dlMacBtn").href = "/api/download/macos?" + q; - document.getElementById("dlWinBtn").href = "/api/download/windows?" + q; + // Stato corrente — popolato da URL o dal form submit. + const dl = { email: "", key: "" }; + + function setStatus(text, isError) { + const el = document.getElementById("dlStatus"); + if (!text) { el.hidden = true; el.textContent = ""; return; } + el.textContent = text; + el.classList.toggle("err", !!isError); + el.hidden = false; + } + + function showDownloadCards(email, key) { + dl.email = email; + dl.key = key; document.getElementById("dlGrid").hidden = false; document.getElementById("dlForm").hidden = true; } - // Se arrivi dall'email con ?key=&email= mostra subito i bottoni + // Click sui bottoni: naviga sulla stessa tab verso /api/download/:platform. + // Il server fa 302 con Content-Disposition: attachment -> il browser + // scarica il file e la pagina /download resta visibile. + document.querySelectorAll(".dl-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.preventDefault(); + if (!dl.email || !dl.key) { + setStatus("Inserisci email e chiave prima di scaricare.", true); + return; + } + const platform = btn.dataset.platform; + const q = "key=" + encodeURIComponent(dl.key) + + "&email=" + encodeURIComponent(dl.email); + setStatus("Avvio del download in corso… il file si apre fra qualche secondo."); + window.location.href = "/api/download/" + platform + "?" + q; + }); + }); + + // Se arrivi dall'email con ?key=&email= mostra subito i bottoni. const urlKey = getParam("key"); const urlEmail = getParam("email"); if (urlKey && urlEmail) { - setDownloadLinks(urlEmail, urlKey); + showDownloadCards(urlEmail, urlKey); } // Form submit @@ -204,10 +252,11 @@ const email = document.getElementById("dlEmail").value.trim(); const key = document.getElementById("dlKey").value.trim(); if (!email || !key) { - alert("Inserisci email e chiave di licenza."); + setStatus("Inserisci email e chiave di licenza.", true); return; } - setDownloadLinks(email, key); + setStatus(""); + showDownloadCards(email, key); }); // Enter dentro i campi