Compare commits

...
2 Commits
Author SHA1 Message Date
luzadev 30be62af78 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".
2026-06-09 11:33:28 +02:00
luzadev a82f6f420c v1.7.1: fix switch tema light/dark
Il selettore Impostazioni > Tema salvava il valore ma nessuno applicava
data-theme al <html>, e mancavano gli override CSS per il tema chiaro.
Aggiunte custom properties light + applyTheme() su init/change/save.
2026-06-09 11:13:04 +02:00
6 changed files with 211 additions and 23 deletions

No files matched your search

+26 -10
View File
@@ -20,7 +20,12 @@ from core.downloader import (
reset_stop as reset_download_stop, reset_stop as reset_download_stop,
is_stopped as download_is_stopped, 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.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
from core.recorder import ( from core.recorder import (
list_input_devices, list_input_devices,
@@ -590,14 +595,11 @@ class Api:
view = "download" view = "download"
token = None 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) has_spotify = any("spotify.com" in u for u in urls)
if has_spotify: if has_spotify and has_credentials:
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
self._log(view, "[INFO] Autenticazione Spotify...") self._log(view, "[INFO] Autenticazione Spotify...")
try: try:
token = get_access_token(client_id, client_secret) token = get_access_token(client_id, client_secret)
@@ -605,6 +607,13 @@ class Api:
self._log(view, f"[ERRORE] Autenticazione fallita: {e}") self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
self._emit("download:done", {"ok": False}) self._emit("download:done", {"ok": False})
return 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): for url_idx, url in enumerate(urls):
if download_is_stopped(): if download_is_stopped():
@@ -664,12 +673,19 @@ class Api:
if is_spotify: if is_spotify:
self._log(view, "[INFO] Recupero informazioni da Spotify...") self._log(view, "[INFO] Recupero informazioni da Spotify...")
try: 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: except Exception as e:
self._log(view, f"[ERRORE] {e}") self._log(view, f"[ERRORE] {e}")
continue continue
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)") 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) download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
else: else:
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}") self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.7.0" VERSION = "v1.7.2"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
+80
View File
@@ -175,3 +175,83 @@ def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]:
else: else:
name, tracks = get_playlist_info(token, item_id) name, tracks = get_playlist_info(token, item_id)
return "Playlist", name, tracks 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'<meta\s+property="og:title"\s+content="([^"]+)"', re.IGNORECASE,
)
_OG_DESC_RE = re.compile(
r'<meta\s+property="og:description"\s+content="([^"]+)"', re.IGNORECASE,
)
# Tag HTML entities che ci interessano nelle og: (Spotify le scrive cosi').
_HTML_ENTITIES = {
"&amp;": "&", "&#x27;": "'", "&apos;": "'",
"&quot;": '"', "&lt;": "<", "&gt;": ">",
}
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/<id>): 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]
+59 -10
View File
@@ -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 { 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-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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -137,7 +155,7 @@
<div class="dl-os">🍎</div> <div class="dl-os">🍎</div>
<h2>macOS</h2> <h2>macOS</h2>
<p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p> <p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p>
<a class="btn btn-primary btn-lg btn-block" id="dlMacBtn" target="_blank" rel="noopener"> <a class="btn btn-primary btn-lg btn-block dl-btn" id="dlMacBtn" data-platform="macos">
Scarica per Mac Scarica per Mac
</a> </a>
</article> </article>
@@ -146,12 +164,14 @@
<div class="dl-os">🪟</div> <div class="dl-os">🪟</div>
<h2>Windows</h2> <h2>Windows</h2>
<p class="meta">Windows 10 e 11 · 64-bit</p> <p class="meta">Windows 10 e 11 · 64-bit</p>
<a class="btn btn-primary btn-lg btn-block" id="dlWinBtn" target="_blank" rel="noopener"> <a class="btn btn-primary btn-lg btn-block dl-btn" id="dlWinBtn" data-platform="windows">
Scarica per Windows Scarica per Windows
</a> </a>
</article> </article>
</div> </div>
<p id="dlStatus" class="dl-status" hidden></p>
<div class="dl-steps"> <div class="dl-steps">
<h2>Installazione macOS</h2> <h2>Installazione macOS</h2>
<ol> <ol>
@@ -184,19 +204,47 @@
return url.searchParams.get(name) || ""; return url.searchParams.get(name) || "";
} }
function setDownloadLinks(email, key) { // Stato corrente — popolato da URL o dal form submit.
const q = "key=" + encodeURIComponent(key) + "&email=" + encodeURIComponent(email); const dl = { email: "", key: "" };
document.getElementById("dlMacBtn").href = "/api/download/macos?" + q;
document.getElementById("dlWinBtn").href = "/api/download/windows?" + q; 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("dlGrid").hidden = false;
document.getElementById("dlForm").hidden = true; 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 urlKey = getParam("key");
const urlEmail = getParam("email"); const urlEmail = getParam("email");
if (urlKey && urlEmail) { if (urlKey && urlEmail) {
setDownloadLinks(urlEmail, urlKey); showDownloadCards(urlEmail, urlKey);
} }
// Form submit // Form submit
@@ -204,10 +252,11 @@
const email = document.getElementById("dlEmail").value.trim(); const email = document.getElementById("dlEmail").value.trim();
const key = document.getElementById("dlKey").value.trim(); const key = document.getElementById("dlKey").value.trim();
if (!email || !key) { if (!email || !key) {
alert("Inserisci email e chiave di licenza."); setStatus("Inserisci email e chiave di licenza.", true);
return; return;
} }
setDownloadLinks(email, key); setStatus("");
showDownloadCards(email, key);
}); });
// Enter dentro i campi // Enter dentro i campi
+34 -2
View File
@@ -58,6 +58,38 @@
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7); --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
} }
/* Tema chiaro: override delle custom properties via data-theme="light"
sull'elemento <html>. I colori brand (verde, blu, viola, ecc.) restano
uguali per non perdere identita'. */
html[data-theme="light"] {
--bg: #FFFFFF;
--bg-elev: #F7F7F8;
--bg-card: #FFFFFF;
--bg-card-2: #F2F3F5;
--bg-input: #EDEEF1;
--bg-input-hover: #E2E4E8;
--border: #E2E4E8;
--divider: #ECEDF0;
--text: #111315;
--text-2: #4A4D52;
--text-3: #8A8E94;
--green-dim: #C8F0D5;
--blue-dim: #D6E6FB;
--purple-dim: #E9D8FD;
--pink-dim: #FBCFE8;
--amber-dim: #FDE68A;
--rec-red-dim: #FECACA;
--indigo-dim: #C7D2FE;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
color-scheme: light;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -1494,13 +1526,13 @@ input[type="number"]::-webkit-inner-spin-button {
.rec-error-log { .rec-error-log {
margin-top: 12px; margin-top: 12px;
background: #0d0d0d; background: var(--bg-card-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
padding: 14px; padding: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 12px;
color: #c0c0c0; color: var(--text-2);
max-height: 320px; max-height: 320px;
overflow: auto; overflow: auto;
white-space: pre-wrap; white-space: pre-wrap;
+11
View File
@@ -440,6 +440,11 @@ function showView(name) {
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
} }
function applyTheme(theme) {
const t = theme === "light" ? "light" : "dark";
document.documentElement.dataset.theme = t;
}
$$(".nav-item").forEach((btn) => { $$(".nav-item").forEach((btn) => {
btn.addEventListener("click", () => showView(btn.dataset.view)); btn.addEventListener("click", () => showView(btn.dataset.view));
}); });
@@ -470,6 +475,7 @@ async function init() {
$("#cookiesInput").value = state.config.cookies_path || ""; $("#cookiesInput").value = state.config.cookies_path || "";
$("#outputInput").value = state.config.output_dir || ""; $("#outputInput").value = state.config.output_dir || "";
$("#themeSelect").value = state.config.theme || "dark"; $("#themeSelect").value = state.config.theme || "dark";
applyTheme(state.config.theme);
// Download tab — usa output_dir come default // Download tab — usa output_dir come default
state.dlOutputDir = state.config.output_dir || ""; state.dlOutputDir = state.config.output_dir || "";
@@ -1027,6 +1033,10 @@ $("#browseOutputBtn").addEventListener("click", async () => {
if (path) $("#outputInput").value = path; if (path) $("#outputInput").value = path;
}); });
$("#themeSelect").addEventListener("change", (e) => {
applyTheme(e.target.value);
});
$("#saveBtn").addEventListener("click", async () => { $("#saveBtn").addEventListener("click", async () => {
const payload = { const payload = {
client_id: $("#clientIdInput").value, client_id: $("#clientIdInput").value,
@@ -1037,6 +1047,7 @@ $("#saveBtn").addEventListener("click", async () => {
output_dir: $("#outputInput").value, output_dir: $("#outputInput").value,
theme: $("#themeSelect").value, theme: $("#themeSelect").value,
}; };
applyTheme(payload.theme);
const res = await window.pywebview.api.save_settings(payload); const res = await window.pywebview.api.save_settings(payload);
if (res.ok) { if (res.ok) {
state.config = payload; state.config = payload;