Compare commits

...
8 Commits
Author SHA1 Message Date
luzadev 6e96e8c6b7 v1.7.8: sticky save button + Spotify Premium chiarito
Impostazioni: il bottone "Salva impostazioni" e' ora sticky in fondo
al viewport, sempre visibile senza dover scrollare in coda alla pagina.

Spotify: aggiornata guida in-app e landing per riflettere il nuovo
requisito Premium per creare le API keys (fino a oggi era gratis con
account Spotify Free; Spotify ha cambiato policy). Singoli brani e
tutte le altre fonti continuano a funzionare senza Premium.

Inclusi anche file gia deployati lato server in sessioni precedenti
(webhook LS, landing icone PNG, download.html Gatekeeper instructions).
2026-06-09 15:42:42 +02:00
luzadev c2599a72a1 v1.7.7: fix avvio Windows definitivo (rimuove Mark of the Web)
La causa root del "Failed to resolve Python.Runtime.Loader.Initialize"
era il Mark of the Web (Zone.Identifier ADS) che Windows aggiunge ai
file estratti da uno zip scaricato dal browser. .NET Framework
rifiuta SILENZIOSAMENTE di risolvere i symbol export degli assembly
managed con MOTW.

Fix multilivello:
- main.py: al primo avvio cancella :Zone.Identifier da ogni
  .dll/.exe/.pyd nel bundle (via ctypes kernel32.DeleteFileW)
- CI: Unblock-File su dist/ pre-zip
- requirements: pythonnet==3.0.5 + clr-loader==0.2.7.post0 pinnati

Vedi: https://github.com/r0x0r/pywebview/issues/1215
      https://github.com/pyinstaller/pyinstaller/issues/7412
2026-06-09 12:37:48 +02:00
luzadev 2cc6c5e9c6 v1.7.6: secondo tentativo fix avvio Windows pythonnet
Preload esplicito di pythonnet.load("netfx") in main.py PRIMA di
importare webview: il lazy-import del 'clr' fallisce sotto PyInstaller
perche' Python.Runtime.dll viene bundlata ma il loader netfx non sa
risolverla.

Aggiunti:
- main.py: bootstrap pythonnet con PYTHONNET_RUNTIME=netfx + load()
- build_windows.py: --clean rebuild, --collect-data pythonnet/clr_loader
  (per Python.Runtime.runtimeconfig.json e altri non-Python files),
  hidden-import clr_loader.netfx
2026-06-09 12:22:04 +02:00
luzadev 891562dc88 v1.7.5: fix avvio Windows ("Failed to resolve Python.Runtime.Loader")
PyInstaller non bundlava i file di supporto di pythonnet/clr_loader
(Python.Runtime.dll, nethost.dll, runtimeconfig.json), per cui al
caricamento di webview.platforms.winforms.py l'import 'clr' moriva.

Aggiunti --collect-all pythonnet/clr_loader + copy-metadata.
Pinnata pythonnet a 3.0.3 (l'ultima compatibile con PyInstaller
senza patch aggiuntive).
2026-06-09 12:11:42 +02:00
luzadev 9d21ca051f v1.7.4: DMG macOS con Fix Gatekeeper integrato
Macos ora viene distribuito come .dmg invece di .zip:
- contiene MusicTools.app + alias Applicazioni + "Fix Gatekeeper.command"
- doppio click sullo script rimuove com.apple.quarantine senza
  Terminal manuale -> niente piu' "App non verificata"

Backend: /api/download serve .dmg con Content-Type
application/x-apple-diskimage. Landing /download aggiornata
con il nuovo flusso (3 step).
2026-06-09 11:53:37 +02:00
luzadev 05ea4c6181 v1.7.3: nuova icona app + landing chiarisce limiti senza chiavi Spotify
Icona: rigenerati icon.icns (macOS multi-size) e icon.ico (Windows
multi-size 16-256) dalla sorgente assets/icon_1024.png.

Landing: nuovo box "Per sfruttare al massimo MusicTools serve un
account Spotify" + FAQ esplicita su cosa funziona senza setup
e dove servono le chiavi.
2026-06-09 11:43:20 +02:00
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
23 changed files with 587 additions and 55 deletions

No files matched your search

+35 -6
View File
@@ -33,17 +33,38 @@ jobs:
- name: Build app
run: python build_macos.py
- name: Package .app -> zip
- name: Create DMG
run: |
cd dist
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip
ls -lh ../MusicTools-macOS.zip
STAGING="dmg_staging"
rm -rf "$STAGING"
mkdir -p "$STAGING"
cp -R dist/MusicTools.app "$STAGING/"
cp "assets/Fix Gatekeeper.command" "$STAGING/"
chmod +x "$STAGING/Fix Gatekeeper.command"
ln -s /Applications "$STAGING/Applications"
cat > "$STAGING/LEGGIMI.txt" <<'EOF'
INSTALLAZIONE MusicTools
========================
1) Trascina MusicTools.app sulla cartella "Applicazioni"
(l'alias e' qui dentro nel DMG)
2) Doppio click su "Fix Gatekeeper.command"
Rimuove la quarantena macOS Gatekeeper (una sola volta).
3) Apri MusicTools dalle Applicazioni e attiva con email + chiave.
Problemi? scrivici a info@djluza.com
EOF
hdiutil create -volname "MusicTools" -srcfolder "$STAGING" \
-ov -format UDZO MusicTools-macOS.dmg
ls -lh MusicTools-macOS.dmg
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MusicTools-macOS
path: MusicTools-macOS.zip
path: MusicTools-macOS.dmg
retention-days: 14
# =========================================================
@@ -66,6 +87,14 @@ jobs:
- name: Build exe
run: python build_windows.py
- name: Unblock files (rimuovi MOTW pre-zip)
shell: pwsh
run: |
# Per ridurre l'incidenza del bug "Failed to resolve
# Python.Runtime.Loader.Initialize" causato da Mark of the Web.
Get-ChildItem -Path dist/MusicTools -Recurse -Include *.dll,*.exe,*.pyd `
| Unblock-File
- name: Package -> zip
shell: pwsh
run: |
@@ -107,5 +136,5 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: true
files: |
artifacts/macos/MusicTools-macOS.zip
artifacts/macos/MusicTools-macOS.dmg
artifacts/windows/MusicTools-Windows.zip
+36 -12
View File
@@ -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,
@@ -37,7 +42,15 @@ from core.upgrader import (
SPOTIFY_GUIDE_TEXT = """\
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
NOTE: Spotify oggi consente di creare le chiavi API SOLO con un account
Spotify Premium attivo. Senza Premium puoi comunque scaricare:
- YouTube, SoundCloud, TikTok, Instagram, Facebook (senza setup)
- Brani Spotify SINGOLI (incolla il link e basta, l'app fa il resto)
Le chiavi Spotify servono solo per playlist e album da Spotify.
Procedura (se hai Spotify Premium):
1) Vai su https://developer.spotify.com/dashboard e accedi.
2) Clicca "Create app".
3) Compila:
- App name: MusicTools (o quello che vuoi)
@@ -50,7 +63,7 @@ SPOTIFY_GUIDE_TEXT = """\
- Client Secret (clicca "View client secret")
5) Incollali qui sotto e premi "Salva".
Note: credenziali gratuite, no Premium. Limite 100 richieste/minuto.
Limite tecnico Spotify: 100 richieste/minuto.
"""
@@ -590,14 +603,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 +615,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 +681,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}")
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Rimuove la quarantena Gatekeeper da MusicTools.app
# Doppio click in macOS apre Terminal ed esegue questo script.
set -e
APP_NAME="MusicTools.app"
# Cerca l'app in /Applications, poi nella stessa cartella del DMG montato.
if [ -d "/Applications/${APP_NAME}" ]; then
APP="/Applications/${APP_NAME}"
elif [ -d "$(dirname "$0")/${APP_NAME}" ]; then
APP="$(dirname "$0")/${APP_NAME}"
else
osascript -e "display alert \"${APP_NAME} non trovata\" message \"Trascina MusicTools.app nella cartella Applicazioni prima di eseguire questo script.\" as critical"
exit 1
fi
echo "Rimuovo la quarantena da: ${APP}"
xattr -dr com.apple.quarantine "${APP}" 2>/dev/null || true
# Notifica visuale
osascript -e "display notification \"MusicTools puo' essere aperta normalmente con doppio click.\" with title \"Fix completato\""
echo ""
echo "Fatto. Ora puoi aprire MusicTools normalmente con doppio click."
echo "Puoi chiudere questa finestra."
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 61 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 207 KiB

+21 -1
View File
@@ -106,6 +106,9 @@ def run_pyinstaller():
"--windowed",
"--onedir",
"--noconfirm",
# Forza rebuild pulita (senza, residui di build vecchi possono
# confondere il bundling di pythonnet/clr_loader).
"--clean",
]
if icon_path.exists():
cmd.extend(["--icon", str(icon_path)])
@@ -115,11 +118,28 @@ def run_pyinstaller():
# Hidden imports per pywebview (EdgeChromium su Windows)
"--hidden-import", "webview",
"--hidden-import", "webview.platforms.edgechromium",
"--hidden-import", "webview.platforms.winforms",
"--hidden-import", "clr",
"--hidden-import", "clr_loader",
"--hidden-import", "clr_loader.netfx",
"--hidden-import", "clr_loader.types",
"--hidden-import", "pythonnet",
"--hidden-import", "requests",
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
# Collegamenti completi: webview, pythonnet, clr_loader (porta
# con se Python.Runtime.dll + nethost.dll + runtimeconfig.json
# altrimenti winforms.py fallisce con "Failed to resolve
# Python.Runtime.Loader.Initialize ...")
"--collect-all", "webview",
"--collect-all", "pythonnet",
"--collect-all", "clr_loader",
"--collect-all", "mutagen",
# collect-data prende anche i file .json/.dll non-Python che
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
# in particolare).
"--collect-data", "pythonnet",
"--collect-data", "clr_loader",
"--copy-metadata", "pythonnet",
"--copy-metadata", "clr_loader",
# Binari bundled
*add_binaries,
# Entry point
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.7.0"
VERSION = "v1.7.8"
APP_NAME = "MusicTools"
+80
View File
@@ -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'<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]
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+70
View File
@@ -91,6 +91,13 @@ a { color: inherit; text-decoration: none; }
font-size: 16px;
}
/* Quando il logo e' un'immagine, niente gradient ne' radius forzato */
.brand-logo-img {
background: none;
border-radius: 0;
object-fit: contain;
}
.nav-links {
display: flex;
gap: 26px;
@@ -601,6 +608,69 @@ a { color: inherit; text-decoration: none; }
text-align: center;
}
/* Box informativo sulle chiavi API Spotify */
.spotify-note {
max-width: 900px;
margin: 48px auto 0;
padding: 28px 32px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
}
.spotify-note h3 {
margin: 0 0 10px;
font-size: 19px;
font-weight: 800;
}
.spotify-note > p {
margin: 0 0 18px;
color: var(--text-muted);
font-size: 15px;
line-height: 1.55;
}
.spotify-note-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin: 18px 0 14px;
}
@media (max-width: 640px) {
.spotify-note-grid { grid-template-columns: 1fr; }
}
.sn-col {
padding: 18px 20px;
border-radius: 12px;
border: 1px solid var(--border);
}
.sn-with {
background: rgba(29, 185, 84, 0.08);
border-color: rgba(29, 185, 84, 0.3);
}
.sn-without {
background: rgba(120, 120, 120, 0.06);
}
.sn-title {
font-weight: 800;
font-size: 14.5px;
margin-bottom: 8px;
color: var(--text);
}
.sn-col ul {
margin: 0;
padding-left: 18px;
color: var(--text-muted);
font-size: 14px;
line-height: 1.55;
}
.sn-col li { margin-bottom: 4px; }
.sn-col strong { color: var(--text); }
.spotify-note-fine {
margin-top: 12px;
font-size: 12.5px;
color: var(--text-dim);
text-align: center;
}
/* ============================================================
FAQ
============================================================ */
+96 -14
View File
@@ -90,6 +90,48 @@
.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-code {
display: block;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
margin: 8px 0 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: var(--accent);
user-select: all;
word-break: break-all;
}
.dl-sub {
margin: 8px 0 0;
padding-left: 20px;
color: var(--text-muted);
font-size: 14.5px;
line-height: 1.6;
}
.dl-sub li { margin-bottom: 4px; }
.dl-sub strong { color: var(--text); }
.dl-sub code { background: var(--bg-card-2); padding: 1px 6px; border-radius: 4px; font-size: 12.5px; }
.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>
</head>
<body>
@@ -137,7 +179,7 @@
<div class="dl-os">🍎</div>
<h2>macOS</h2>
<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
</a>
</article>
@@ -146,19 +188,30 @@
<div class="dl-os">🪟</div>
<h2>Windows</h2>
<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
</a>
</article>
</div>
<p id="dlStatus" class="dl-status" hidden></p>
<div class="dl-steps">
<h2>Installazione macOS</h2>
<ol>
<li>Apri il file <code>.zip</code> scaricato — viene estratto in <code>MusicTools.app</code></li>
<li>Trascina <strong>MusicTools.app</strong> nella cartella <strong>Applicazioni</strong></li>
<li>Vai in Applicazioni, <strong>tasto destro</strong> su MusicTools → <strong>Apri</strong> → <strong>Apri</strong> (solo la prima volta, e' la procedura standard per app non firmate)</li>
<li>Inserisci email e chiave per attivare</li>
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
<li>
Autorizza l'app (una sola volta, perche' non e' firmata con Developer ID Apple).<br>
<strong>Modo piu' semplice:</strong> apri il <strong>Terminale</strong> (cmd+spazio &rarr; "Terminale"), incolla questa riga e premi Invio:
<p class="dl-code">xattr -dr com.apple.quarantine /Applications/MusicTools.app</p>
<strong>In alternativa</strong> (senza Terminale):
<ul class="dl-sub">
<li><strong>Tasto destro</strong> su <code>Fix Gatekeeper.command</code> nel DMG &rarr; <strong>Apri</strong> &rarr; <strong>Apri</strong>. Se compare il blocco "non e' in grado di verificare", clicca <strong>Annulla</strong> e procedi col passo successivo.</li>
<li>Apri <strong>Impostazioni di Sistema</strong> &rarr; <strong>Privacy e sicurezza</strong>. In fondo vedi "Fix Gatekeeper.command e' stato bloccato": clicca <strong>Apri comunque</strong>.</li>
</ul>
</li>
<li>Apri <strong>MusicTools</strong> dalle Applicazioni, inserisci email e chiave per attivare</li>
</ol>
</div>
@@ -184,19 +237,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 +285,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
+64 -5
View File
@@ -11,7 +11,9 @@
<meta property="og:type" content="website" />
<meta property="og:url" content="https://musictools.djluza.com" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon-192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="assets/favicon-192.png" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
@@ -20,7 +22,7 @@
<header class="nav">
<div class="container nav-row">
<a href="#top" class="brand">
<span class="brand-logo">♫</span>
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
<span class="brand-name">MusicTools</span>
</a>
<nav class="nav-links">
@@ -284,6 +286,41 @@
</p>
</div>
<div class="spotify-note">
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
<p>
MusicTools funziona <strong>al 100% subito</strong> con YouTube,
SoundCloud, TikTok, Instagram, Facebook e i singoli brani Spotify
(anche con account Spotify gratuito).<br>
Per scaricare <strong>playlist e album interi da Spotify</strong> servono
le tue <strong>chiavi API Spotify</strong>, che oggi Spotify rilascia solo
a chi ha un <strong>account Premium</strong> attivo (le crei tu dal
Developer Dashboard, in 2 minuti, con la guida integrata nell'app).
</p>
<div class="spotify-note-grid">
<div class="sn-col sn-with">
<div class="sn-title">✓ Con Spotify Premium + chiavi API</div>
<ul>
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
<li>Tutte le altre fonti restano sempre attive</li>
</ul>
</div>
<div class="sn-col sn-without">
<div class="sn-title">Senza Premium (modalita' base)</div>
<ul>
<li>Da Spotify scarichi <strong>un brano alla volta</strong> incollando il link</li>
<li>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
</ul>
</div>
</div>
<p class="spotify-note-fine">
MusicTools non e' affiliato a Spotify. Le chiavi API restano sul tuo
dispositivo, non passano per i nostri server.
</p>
</div>
<div class="use-policy">
<h3>Uso responsabile</h3>
<p>
@@ -343,8 +380,30 @@
</details>
<details class="faq-item">
<summary>Devo creare un account Spotify Premium o YouTube Premium?</summary>
<p>No. Per Spotify ti basta un account gratuito (serve solo per ottenere le credenziali API dal loro dashboard, è gratis). YouTube non richiede account.</p>
<summary>Mi serve un abbonamento Spotify Premium?</summary>
<p>
Solo se vuoi scaricare <strong>playlist e album interi</strong> da Spotify:
in quel caso servono le chiavi API Spotify, che oggi Spotify rilascia
solo agli account Premium. Con un account Spotify gratuito (o senza
account Spotify) <strong>puoi comunque scaricare</strong> da YouTube,
SoundCloud, TikTok, IG, FB e i singoli brani Spotify incollando il link.
</p>
<p>YouTube non richiede alcun account.</p>
</details>
<details class="faq-item">
<summary>Cosa posso fare senza configurare le chiavi API Spotify?</summary>
<p>
<strong>Da YouTube, SoundCloud, TikTok, Instagram, Facebook</strong>:
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
</p>
<p>
<strong>Da Spotify</strong>: puoi scaricare <strong>un brano alla volta</strong>
incollando il link al singolo brano (l'app estrae artista e titolo
dai metadati pubblici di Spotify e cerca su YouTube). Per <strong>album
e playlist</strong> servono le chiavi API, che Spotify rilascia solo
agli account Premium.
</p>
</details>
<details class="faq-item">
@@ -403,7 +462,7 @@
<footer class="footer">
<div class="container footer-row">
<div class="footer-brand">
<span class="brand-logo">♫</span>
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
<span>MusicTools</span>
<span class="footer-by">by LuZa</span>
</div>
+40
View File
@@ -4,6 +4,46 @@ import os
import sys
from pathlib import Path
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare
# webview. Con PyInstaller il lazy-loader fallisce con
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
# Python.Runtime.dll viene bundlata ma il loader non sa risolverla
# senza l'inizializzazione esplicita. Fix conferma:
# https://github.com/pythonnet/pythonnet/issues/2178
if sys.platform == "win32":
# ============================================================
# Fix Windows: rimuovi Mark of the Web (MOTW) dalle DLL bundled.
# Quando l'utente scarica il .zip dal browser, Windows aggiunge
# uno stream NTFS Zone.Identifier a TUTTI i file estratti.
# .NET Framework 4.x rifiuta silenziosamente di risolvere i
# symbol export degli assembly managed con MOTW -> il loader
# netfx di pythonnet fallisce con "Failed to resolve
# Python.Runtime.Loader.Initialize".
# Soluzione: cancelliamo lo stream :Zone.Identifier da ogni
# .dll/.exe del bundle al primo avvio (operazione idempotente
# e sicura: l'utente ha gia eseguito l'exe esplicitamente).
# Vedi: https://github.com/r0x0r/pywebview/issues/1215
# ============================================================
if getattr(sys, "frozen", False):
try:
import ctypes
_kernel32 = ctypes.windll.kernel32
_bundle = Path(sys._MEIPASS) # type: ignore[attr-defined]
for _f in _bundle.rglob("*"):
if _f.suffix.lower() in (".dll", ".exe", ".pyd"):
_kernel32.DeleteFileW(f"{_f}:Zone.Identifier")
except Exception as _e:
print(f"[bootstrap] MOTW cleanup failed: {_e}")
try:
if getattr(sys, "frozen", False):
os.environ["PYTHONNET_RUNTIME"] = "netfx"
from pythonnet import load as _pythonnet_load
_pythonnet_load("netfx")
import clr # noqa: F401 - inizializza il loader
except Exception as _e:
print(f"[bootstrap] preload pythonnet failed: {_e}")
import webview
from api.bridge import Api
+2 -1
View File
@@ -5,4 +5,5 @@ mutagen>=1.47.0
pyobjc-core>=10.0 ; sys_platform == "darwin"
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
pythonnet>=3.0 ; sys_platform == "win32"
pythonnet==3.0.5 ; sys_platform == "win32"
clr-loader==0.2.7.post0 ; sys_platform == "win32"
+50 -11
View File
@@ -73,18 +73,31 @@ async function handleOrderCreated(payload, attrs, t) {
const planCode = planByVariantId(variantId);
const plan = planCode ? getPlan(planCode) : null;
// Subscriptions: il record vero arriva con subscription_created.
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione
// non ancora generata), accettiamo l'evento ma non creiamo nulla.
if (plan && plan.is_subscription) {
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } };
// Se l'order e' gia stato processato (lo riconosciamo dall'order_id),
// non duplichiamo licenza ne' email — ack 200 e basta.
const existing = await one(
"SELECT id FROM licenses WHERE order_id=? LIMIT 1",
[orderId],
);
if (existing) {
return { status: 200, body: { ok: true, duplicate: true } };
}
// Annual one-time (o piano non mappato -> trattalo come annual di default
// cosi' non lasciamo l'utente senza licenza se ha pagato).
// Piano non mappato -> fallback annual cosi' non blocchiamo l'utente
// che ha gia pagato (la licenza sara' usabile anche se non sappiamo
// i limiti esatti del piano comprato).
const planForRecord = plan?.code || "annual";
const limit = plan?.daily_limit ?? null;
const expiresAt = t + ANNUAL_TTL_SECONDS;
const planMeta = plan || getPlan("annual");
const limit = planMeta?.daily_limit ?? null;
// Annual one-time: settiamo expires_at = now + 365d.
// Subscription mensili: NON settiamo current_period_end qui — lo fara'
// subscription_created/subscription_payment_success quando arriva.
// Se quell'evento non arrivasse mai (perche' non abilitato in LS
// dashboard), la licenza resta utilizzabile (current_period_end NULL
// = no scadenza imposta lato server: la sottoscrizione vivra' o
// morira' su LS, e i webhook successivi aggiorneranno lo status).
const expiresAt = planMeta?.is_subscription ? null : t + ANNUAL_TTL_SECONDS;
const key = generateLicenseKey();
try {
@@ -103,7 +116,10 @@ async function handleOrderCreated(payload, attrs, t) {
}
try {
await sendLicenseEmail(email, key, { plan: planForRecord, expiresAt });
await sendLicenseEmail(email, key, {
plan: planForRecord,
expiresAt: expiresAt,
});
} catch (e) {
console.error("[email] send failed:", e?.message || e);
}
@@ -141,7 +157,7 @@ async function handleSubscriptionCreated(payload, attrs, t) {
const key = generateLicenseKey();
// Idempotenza: se ricevo lo stesso subscription_created due volte,
// riuso la licenza esistente (non ne creo un'altra).
// riuso la licenza esistente.
const existing = await one(
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
[subId],
@@ -156,6 +172,29 @@ async function handleSubscriptionCreated(payload, attrs, t) {
return { status: 200, body: { ok: true, duplicate: true } };
}
// Caso normale ora: la licenza e' gia stata creata da order_created
// (con subscription_id NULL). La aggiorno con subscription_id e
// current_period_end senza generare una nuova chiave ne' una nuova email.
const byOrder = orderId
? await one(
`SELECT id FROM licenses
WHERE order_id=? AND subscription_id IS NULL LIMIT 1`,
[orderId],
)
: null;
if (byOrder) {
await exec(
`UPDATE licenses
SET subscription_id=?, current_period_end=?, plan=?, daily_limit=?,
updated_at=?
WHERE id=?`,
[subId, periodEnd, planCode, plan.daily_limit, t, byOrder.id],
);
return { status: 200, body: { ok: true, attached_subscription: subId } };
}
// Fallback: nessuna licenza esistente -> ne creiamo una nuova
// (puo' succedere se order_created non e' arrivato per qualche motivo).
try {
await exec(
`INSERT INTO licenses
+7 -1
View File
@@ -89,7 +89,13 @@ export async function download(req, res) {
}
const name = path.basename(abs);
res.setHeader("Content-Type", "application/zip");
const ext = path.extname(name).toLowerCase();
const contentType = ext === ".dmg"
? "application/x-apple-diskimage"
: ext === ".exe"
? "application/vnd.microsoft.portable-executable"
: "application/zip";
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
fs.createReadStream(abs).pipe(res);
}
+47 -3
View File
@@ -58,6 +58,38 @@
--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;
margin: 0;
@@ -841,11 +873,23 @@ input[type="number"]::-webkit-inner-spin-button {
white-space: pre-line;
}
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
sempre visibile mentre l'utente scrolla la tab Impostazioni. Niente
ricerca del pulsante in coda. */
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
sempre visibile mentre l'utente scrolla la tab Impostazioni. */
.save-row {
display: flex;
align-items: center;
gap: 16px;
margin: 20px 0 32px;
position: sticky;
bottom: -24px;
padding: 16px 32px;
margin: 24px -32px -32px;
background: var(--bg);
border-top: 1px solid var(--divider);
box-shadow: 0 -8px 16px rgba(0, 0, 0, 0.25);
z-index: 5;
}
.save-status {
@@ -1494,13 +1538,13 @@ input[type="number"]::-webkit-inner-spin-button {
.rec-error-log {
margin-top: 12px;
background: #0d0d0d;
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #c0c0c0;
color: var(--text-2);
max-height: 320px;
overflow: auto;
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));
}
function applyTheme(theme) {
const t = theme === "light" ? "light" : "dark";
document.documentElement.dataset.theme = t;
}
$$(".nav-item").forEach((btn) => {
btn.addEventListener("click", () => showView(btn.dataset.view));
});
@@ -470,6 +475,7 @@ async function init() {
$("#cookiesInput").value = state.config.cookies_path || "";
$("#outputInput").value = state.config.output_dir || "";
$("#themeSelect").value = state.config.theme || "dark";
applyTheme(state.config.theme);
// Download tab — usa output_dir come default
state.dlOutputDir = state.config.output_dir || "";
@@ -1027,6 +1033,10 @@ $("#browseOutputBtn").addEventListener("click", async () => {
if (path) $("#outputInput").value = path;
});
$("#themeSelect").addEventListener("change", (e) => {
applyTheme(e.target.value);
});
$("#saveBtn").addEventListener("click", async () => {
const payload = {
client_id: $("#clientIdInput").value,
@@ -1037,6 +1047,7 @@ $("#saveBtn").addEventListener("click", async () => {
output_dir: $("#outputInput").value,
theme: $("#themeSelect").value,
};
applyTheme(payload.theme);
const res = await window.pywebview.api.save_settings(payload);
if (res.ok) {
state.config = payload;