Compare commits

...
3 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
16 changed files with 207 additions and 38 deletions

No files matched your search

+8
View File
@@ -87,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: |
+10 -2
View File
@@ -42,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)
@@ -55,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.
"""
+11
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)])
@@ -118,6 +121,9 @@ def run_pyinstaller():
"--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: webview, pythonnet, clr_loader (porta
# con se Python.Runtime.dll + nethost.dll + runtimeconfig.json
@@ -127,6 +133,11 @@ def run_pyinstaller():
"--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
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.7.5"
VERSION = "v1.7.8"
APP_NAME = "MusicTools"
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

+7
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;
+35 -2
View File
@@ -91,6 +91,30 @@
.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;
@@ -177,8 +201,17 @@
<ol>
<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>Doppio click su <strong>Fix Gatekeeper.command</strong> (dentro il DMG): autorizza l'app in 1 click. Si chiude da sola appena finito.</li>
<li>Apri MusicTools dalle Applicazioni, inserisci email e chiave per attivare</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>
+30 -20
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">
@@ -285,17 +287,19 @@
</div>
<div class="spotify-note">
<h3>Per sfruttare al massimo MusicTools serve un account Spotify</h3>
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
<p>
L'app raggiunge il <strong>100% delle sue funzioni</strong> quando colleghi
le tue <strong>chiavi API Spotify</strong>. Sono <strong>gratuite</strong>:
basta un account Spotify (anche <strong>quello gratis</strong>, niente
Premium richiesto) e bastano <strong>2 minuti</strong> per generarle dalla
Developer Dashboard. La guida passo-passo e' integrata nell'app.
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 chiavi API Spotify (consigliato)</div>
<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>
@@ -303,9 +307,9 @@
</ul>
</div>
<div class="sn-col sn-without">
<div class="sn-title">Senza chiavi (modalita' base)</div>
<div class="sn-title">Senza Premium (modalita' base)</div>
<ul>
<li>Da Spotify scarichi <strong>un brano alla volta</strong></li>
<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>
@@ -376,8 +380,15 @@
</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">
@@ -387,12 +398,11 @@
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
</p>
<p>
<strong>Da Spotify</strong>: senza chiavi 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
— sono gratis (anche con account Spotify free) e la procedura guidata
nell'app ci mette 2 minuti.
<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>
@@ -452,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.3 ; 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
+13 -1
View File
@@ -873,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 {