Compare commits

..
14 Commits
Author SHA1 Message Date
luzadev 7dd793253b v1.7.13: fix definitivo TCC subprocess - sub-bundle .app per ffmpeg/ffprobe
Crash report ha dato la spiegazione precisa:
"Namespace TCC, Code 0 - This app has crashed because it attempted
to access privacy-sensitive data without a usage description.
The app's Info.plist must contain an NSMicrophoneUsageDescription key."

macOS 14+ richiede che ogni binario che apre device privacy-sensitive
abbia il suo PROPRIO Info.plist con NSMicrophoneUsageDescription, NON
basta che il main MusicTools lo abbia. Per binari standalone come
ffmpeg, non c'e' Info.plist associato -> SIGABRT immediato.

Fix: incapsulare ffmpeg e ffprobe in mini-bundle .app dentro
MusicTools.app/Contents/Frameworks/ffmpeg.app/Contents/MacOS/ffmpeg
con il proprio Info.plist contenente NSMicrophoneUsageDescription.
Aggiunto rpath @executable_path/../../../ per puntare alle dylib
di Contents/Frameworks/. Codesign di ogni sub-bundle con identifier
com.djluza.musictools.{ffmpeg,ffprobe}.

core/paths.py aggiornato per cercare i binari anche dentro i
sub-bundle .app oltre che in Contents/Frameworks/ legacy.

L'utente al primo lancio della registrazione vedra' un prompt
microfono per "ffmpeg" (in aggiunta a quello del main MusicTools).
2026-06-09 22:44:58 +02:00
luzadev 5771d631fd v1.7.12: codesign identifier coerente per TCC subprocess + skip quota su Annual
Bug: codesign --deep ad-hoc dava a ffmpeg un identifier auto-generato
(ffmpeg-<hash>) diverso da com.djluza.musictools del bundle, quindi
macOS trattava ffmpeg come app distinta per TCC -> permesso microfono
del main NON ereditato dal subprocess -> registrazione muta o errore.

Fix: in adhoc_codesign() ora firmiamo OGNI Mach-O nested con
--identifier com.djluza.musictools (stesso del bundle), poi firmiamo
il bundle .app. Cosi' TCC vede un unico "team" coerente e il
permesso microfono si propaga a ffmpeg/ffprobe/yt-dlp. Verificato
in dev mode: registrazione BlackHole funziona quando ffmpeg eredita
correttamente il TCC.

Anche fix collaterale: bridge._gate() salta la chiamata consume_quota
al server quando il piano corrente ha daily_limit=None (Annual). Per
i piani unlimited non c'e' motivo di chiamare il server -> niente
piu' errori online se il server e' temporaneamente irraggiungibile
durante una registrazione su un piano Annual.
2026-06-09 22:14:53 +02:00
luzadev b74a0a53ec v1.7.11: fix definitivo BlackHole — ad-hoc codesign del bundle
Causa root: MusicTools.app ha il permesso TCC microfono, ma quando
lancia ffmpeg come subprocess macOS tratta ffmpeg come binario non
firmato distinto -> AVFoundation apre il device senza errore ma
restituisce solo silenzio.

Fix in build_macos.py: dopo PyInstaller + Info.plist, applico
`codesign --force --deep --sign -` ad-hoc su tutto il bundle.
La firma e' "free" (no Developer ID) ma rende l'app un singolo
team coerente per TCC, quindi il permesso del main si propaga
anche a ffmpeg/ffprobe/yt-dlp.
2026-06-09 21:29:30 +02:00
luzadev 415175ec4d v1.7.10: fix registrazione BlackHole muta (device by name)
QuickTime registrava da BlackHole, MusicTools no: il bug era che
list_input_devices passava al record l'index AVFoundation, ma
l'index puo' rinumerarsi tra l'elenco e l'apertura (iPhone in
continuita', Multi-Output Device, hot-plug). Quando ffmpeg apriva
:<index> col numero sbagliato, registrava silenzio dal device
"vero" mappato a quel numero.

Fix: in _list_macos uso il NOME del device come id (es.
"BlackHole 2ch"). ffmpeg AVFoundation accetta sia :<index> sia
:<name> ed il nome e' stabile. Aggiunto campo 'index' solo per
log/diagnostica. Nessun cambio Windows (gia' faceva by-name).
2026-06-09 20:58:51 +02:00
luzadev d8349fb79f v1.7.9: fix permessi microfono macOS (BlackHole non funzionava)
Senza NSMicrophoneUsageDescription nell'Info.plist macOS NEGA
silenziosamente l'accesso al microfono (incluso BlackHole come
dispositivo input) e l'app non compare nemmeno in Impostazioni di
Sistema > Privacy e sicurezza > Microfono, quindi l'utente non puo
nemmeno autorizzarla manualmente.

Aggiunto patch_info_plist() in build_macos.py che dopo PyInstaller
aggiunge via PlistBuddy:
- NSMicrophoneUsageDescription (causa primaria)
- CFBundleIdentifier stabile (com.djluza.musictools)
- LSApplicationCategoryType = music
2026-06-09 20:13:41 +02:00
luzadev 1414c5199c Legal: aggiornati ToS/Refund/Privacy per i 4 piani + Spotify Premium
terms.html:
- §9: aggiunto paragrafo su credenziali Spotify Web API che richiedono
  account Spotify Premium (policy aggiornata da Spotify, non da noi);
  fallback brano singolo non richiede Premium.
- §12: sostituito il vecchio prezzo unico 39,90 € con i 4 piani
  (Basic 2,99 / Pro 5,99 / Premium 9,99 / Annual 49,90), limiti
  giornalieri, regola reset Europe/Rome, rinnovo vs una-tantum.
- §13: chiarito che recesso 14gg vale per il primo acquisto; rinnovi
  mensili interrompibili via disdetta.

refund.html: aggiunte differenze per piano, sezione disdetta dashboard
Lemon Squeezy, disclaimer Spotify.

privacy.html: aggiunti come dati trattati piano/order_id/subscription_id/
contatore download giornaliero (90gg di retention); rimosso riferimento
errato a Cloudflare (legacy).
2026-06-09 20:11:57 +02:00
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
29 changed files with 911 additions and 106 deletions

No files matched your search

+35 -6
View File
@@ -33,17 +33,38 @@ jobs:
- name: Build app - name: Build app
run: python build_macos.py run: python build_macos.py
- name: Package .app -> zip - name: Create DMG
run: | run: |
cd dist STAGING="dmg_staging"
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip rm -rf "$STAGING"
ls -lh ../MusicTools-macOS.zip 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 - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: MusicTools-macOS name: MusicTools-macOS
path: MusicTools-macOS.zip path: MusicTools-macOS.dmg
retention-days: 14 retention-days: 14
# ========================================================= # =========================================================
@@ -66,6 +87,14 @@ jobs:
- name: Build exe - name: Build exe
run: python build_windows.py 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 - name: Package -> zip
shell: pwsh shell: pwsh
run: | run: |
@@ -107,5 +136,5 @@ jobs:
generate_release_notes: true generate_release_notes: true
fail_on_unmatched_files: true fail_on_unmatched_files: true
files: | files: |
artifacts/macos/MusicTools-macOS.zip artifacts/macos/MusicTools-macOS.dmg
artifacts/windows/MusicTools-Windows.zip artifacts/windows/MusicTools-Windows.zip
+40 -11
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,
@@ -37,7 +42,15 @@ from core.upgrader import (
SPOTIFY_GUIDE_TEXT = """\ 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". 2) Clicca "Create app".
3) Compila: 3) Compila:
- App name: MusicTools (o quello che vuoi) - App name: MusicTools (o quello che vuoi)
@@ -50,7 +63,7 @@ SPOTIFY_GUIDE_TEXT = """\
- Client Secret (clicca "View client secret") - Client Secret (clicca "View client secret")
5) Incollali qui sotto e premi "Salva". 5) Incollali qui sotto e premi "Salva".
Note: credenziali gratuite, no Premium. Limite 100 richieste/minuto. Limite tecnico Spotify: 100 richieste/minuto.
""" """
@@ -216,7 +229,12 @@ class Api:
), ),
"plan": plan, "plan": plan,
} }
# 2) Quota gate server-side # 2) Quota gate server-side: skippato per i piani senza limite
# giornaliero (Annual) — evita una chiamata HTTP inutile e fa
# funzionare l'app anche offline per quei piani.
plan = license_mod.get_plan()
if plan.get("daily_limit") is None:
return None
try: try:
res = license_mod.consume_quota(feature) res = license_mod.consume_quota(feature)
except license_mod.LicenseNetworkError as e: except license_mod.LicenseNetworkError as e:
@@ -590,14 +608,11 @@ class Api:
view = "download" view = "download"
token = None token = None
has_spotify = any("spotify.com" in u for u in urls)
if has_spotify:
client_id = cfg.get("client_id", "") client_id = cfg.get("client_id", "")
client_secret = cfg.get("client_secret", "") client_secret = cfg.get("client_secret", "")
if not client_id or not client_secret: has_credentials = bool(client_id and client_secret)
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.") has_spotify = any("spotify.com" in u for u in urls)
self._emit("download:done", {"ok": False}) if has_spotify and has_credentials:
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 +620,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 +686,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:
if token:
label, name, tracks = resolve_spotify_url(token, url) 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}")
+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

+234
View File
@@ -238,6 +238,9 @@ def run_pyinstaller():
app_path = DIST_DIR / "MusicTools.app" app_path = DIST_DIR / "MusicTools.app"
if app_path.exists(): if app_path.exists():
log(f"Build completata: {app_path}") log(f"Build completata: {app_path}")
patch_info_plist(app_path)
wrap_subprocess_in_bundle(app_path)
adhoc_codesign(app_path)
# Mostra dimensione # Mostra dimensione
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file()) size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
log(f"Dimensione: {size / 1024 / 1024:.0f} MB") log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
@@ -249,6 +252,237 @@ def run_pyinstaller():
log(f"Trovata directory: {alt}") log(f"Trovata directory: {alt}")
def patch_info_plist(app_path):
"""Aggiunge le chiavi TCC (Transparency, Consent and Control) richieste
da macOS nel bundle .app, altrimenti macOS nega silenziosamente l'accesso
al microfono e l'app NON compare in Impostazioni > Privacy e sicurezza.
Senza NSMicrophoneUsageDescription, qualsiasi tentativo di ffmpeg di
aprire un dispositivo audio (incluso BlackHole) viene rifiutato dal
sistema e non viene mostrato nessun prompt all'utente.
"""
info_plist = app_path / "Contents" / "Info.plist"
if not info_plist.exists():
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
return
pb = "/usr/libexec/PlistBuddy"
# Helper: aggiungi una chiave; se gia presente sovrascrivila.
def add_or_set(key, value, ptype="string"):
# Tenta Add; se esiste, fa Set.
r = subprocess.run(
[pb, "-c", f'Add :{key} {ptype} "{value}"', str(info_plist)],
capture_output=True, text=True,
)
if r.returncode != 0 and "already exists" in (r.stderr + r.stdout).lower():
subprocess.run(
[pb, "-c", f'Set :{key} "{value}"', str(info_plist)],
check=True,
)
log("Patching Info.plist con le chiavi TCC...")
add_or_set(
"NSMicrophoneUsageDescription",
"MusicTools usa il microfono per registrare l'audio del sistema "
"(ad esempio tramite BlackHole o un altro dispositivo loopback).",
)
# Sicurezza extra: dichiarare bundle identifier stabile aiuta il
# database TCC a riconoscere coerentemente l'app tra le sessioni.
add_or_set("CFBundleIdentifier", "com.djluza.musictools")
add_or_set("LSApplicationCategoryType", "public.app-category.music")
log("Info.plist aggiornato.")
_BUNDLE_ID = "com.djluza.musictools"
def _subprocess_info_plist(executable, bundle_id):
"""Info.plist per un mini-bundle che incapsula un binario subprocess.
NSMicrophoneUsageDescription qui dentro e' la chiave: senza di essa,
macOS uccide il subprocess con SIGABRT appena tenta di aprire un
device audio, anche se il main MusicTools ha il proprio TCC concesso.
"""
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
'<plist version="1.0">\n'
'<dict>\n'
f' <key>CFBundleExecutable</key><string>{executable}</string>\n'
f' <key>CFBundleIdentifier</key><string>{bundle_id}</string>\n'
f' <key>CFBundleName</key><string>{executable}</string>\n'
' <key>CFBundlePackageType</key><string>APPL</string>\n'
' <key>CFBundleShortVersionString</key><string>1.0</string>\n'
' <key>CFBundleVersion</key><string>1</string>\n'
' <key>LSBackgroundOnly</key><true/>\n'
' <key>LSUIElement</key><true/>\n'
' <key>NSMicrophoneUsageDescription</key>\n'
' <string>MusicTools usa il microfono per registrare l\'audio del '
'sistema (BlackHole o altri dispositivi loopback).</string>\n'
'</dict>\n'
'</plist>\n'
)
def wrap_subprocess_in_bundle(app_path):
"""Sposta ffmpeg e ffprobe in mini-bundle .app dentro Frameworks/.
macOS 14+ ENFORCE il fatto che ogni binario che apre device
privacy-sensitive (microfono, camera, ...) debba avere un proprio
Info.plist accessibile con NSMicrophoneUsageDescription. Quando
ffmpeg viene lanciato come binario standalone (anche se figlio di
MusicTools), non c'e' Info.plist associato -> SIGABRT.
Soluzione: incapsulare ffmpeg in MusicTools.app/Contents/Frameworks/
ffmpeg.app/Contents/MacOS/ffmpeg, con Info.plist nel suo bundle.
Stesso per ffprobe.
"""
log("Wrapping ffmpeg e ffprobe in sub-bundle .app...")
fw = app_path / "Contents" / "Frameworks"
if not fw.exists():
log("ATTENZIONE: Contents/Frameworks non trovato, skip wrapping.")
return
for name in ("ffmpeg", "ffprobe"):
src = fw / name
if not src.exists() or not src.is_file():
log(f" skip {name}: non trovato in Frameworks/")
continue
wrap = fw / f"{name}.app"
if wrap.exists():
shutil.rmtree(wrap)
macos = wrap / "Contents" / "MacOS"
macos.mkdir(parents=True, exist_ok=True)
dest = macos / name
shutil.move(str(src), str(dest))
dest.chmod(0o755)
# Aggiungi un nuovo rpath che punta a Contents/Frameworks/ (dove
# stanno le dylib del bundle MusicTools).
# @executable_path = ffmpeg.app/Contents/MacOS/
# ../../../ = MusicTools.app/Contents/Frameworks/
subprocess.run(
["install_name_tool", "-add_rpath",
"@executable_path/../../../", str(dest)],
capture_output=True,
)
# Crea Info.plist con NSMicrophoneUsageDescription.
info = wrap / "Contents" / "Info.plist"
bundle_id = f"com.djluza.musictools.{name}"
info.write_text(_subprocess_info_plist(name, bundle_id))
log(f" wrap: {name} -> {dest.relative_to(app_path)}")
def adhoc_codesign(app_path):
"""Ad-hoc codesign dell'intero bundle .app con identifier coerente.
macOS tratta ogni binario Mach-O firmato come una "app" distinta per
le decisioni TCC (microfono, camera, ecc.). Con `codesign --deep`
standard, i nested binary (ffmpeg, ffprobe, yt-dlp) ricevono ognuno
un identifier auto-generato del tipo `<nome>-<hash>`, diverso da
quello del bundle principale -> TCC NEGA al subprocess.
Soluzione: firmiamo PRIMA ogni binario interno con `--identifier
com.djluza.musictools`, poi il bundle .app intero. Cosi' TCC vede
tutti i Mach-O come parte dello stesso "team" e propaga il permesso
del main ai subprocess.
"""
log("Ad-hoc codesign del bundle (identifier coerente per TCC)...")
try:
# 1) Pulisci attributi estesi (rimuove quarantine residui dal build)
subprocess.run(["xattr", "-cr", str(app_path)], capture_output=True)
# 2) Trova tutti i Mach-O nested (binari ed eventuali .dylib).
# Li firmiamo uno a uno con identifier coerente, dal piu' profondo
# al piu' esterno (richiesta da codesign).
nested_machos = []
for p in sorted(app_path.rglob("*"), key=lambda x: -len(x.parts)):
if not p.is_file():
continue
# Skip risorse non eseguibili
if p.suffix in (".plist", ".nib", ".png", ".icns", ".svg",
".html", ".css", ".js", ".json", ".md", ".txt"):
continue
try:
with open(p, "rb") as f:
magic = f.read(4)
except Exception:
continue
# Mach-O magic numbers (32/64-bit, big/little endian, fat)
if magic in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"):
nested_machos.append(p)
log(f"Trovati {len(nested_machos)} binari Mach-O da firmare.")
for p in nested_machos:
# Per ffmpeg/ffprobe dentro al sub-bundle .app, usa l'identifier
# del SUB-bundle (TCC li tratta come app separate con Info.plist
# proprio). Per gli altri binari nested (dylib, framework), usa
# l'identifier del bundle principale.
ident = _BUNDLE_ID
parts = p.relative_to(app_path).parts
for sub in ("ffmpeg.app", "ffprobe.app"):
if sub in parts:
ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
break
r = subprocess.run(
["codesign", "--force", "--sign", "-",
"--identifier", ident,
"--timestamp=none",
str(p)],
capture_output=True, text=True,
)
if r.returncode != 0:
log(f" warn: firma {p.name} fallita: {r.stderr.strip()}")
# Firma anche i sub-bundle .app come bundle (con il loro Info.plist).
for sub in ("ffmpeg.app", "ffprobe.app"):
sub_path = app_path / "Contents" / "Frameworks" / sub
if not sub_path.exists():
continue
sub_ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
r = subprocess.run(
["codesign", "--force", "--sign", "-",
"--identifier", sub_ident,
"--timestamp=none",
str(sub_path)],
capture_output=True, text=True,
)
if r.returncode != 0:
log(f" warn: firma {sub} fallita: {r.stderr.strip()}")
# 3) Firma del bundle .app principale (l'identifier corretto viene
# letto dall'Info.plist - CFBundleIdentifier che gia' settiamo).
r = subprocess.run(
["codesign", "--force", "--sign", "-",
"--identifier", _BUNDLE_ID,
"--timestamp=none",
str(app_path)],
capture_output=True, text=True,
)
if r.returncode != 0:
log(f"ATTENZIONE: firma bundle fallita: {r.stderr.strip()}")
else:
log("Codesign ad-hoc completato.")
# 4) Verifica finale
verify = subprocess.run(
["codesign", "--verify", "--deep", "--strict", str(app_path)],
capture_output=True, text=True,
)
if verify.returncode == 0:
log("Verifica firma OK.")
else:
log(f"NOTA verifica firma: {verify.stderr.strip()}")
except FileNotFoundError:
log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing")
# ========================================================================= # =========================================================================
# Main # Main
# ========================================================================= # =========================================================================
+21 -1
View File
@@ -106,6 +106,9 @@ def run_pyinstaller():
"--windowed", "--windowed",
"--onedir", "--onedir",
"--noconfirm", "--noconfirm",
# Forza rebuild pulita (senza, residui di build vecchi possono
# confondere il bundling di pythonnet/clr_loader).
"--clean",
] ]
if icon_path.exists(): if icon_path.exists():
cmd.extend(["--icon", str(icon_path)]) cmd.extend(["--icon", str(icon_path)])
@@ -115,11 +118,28 @@ def run_pyinstaller():
# Hidden imports per pywebview (EdgeChromium su Windows) # Hidden imports per pywebview (EdgeChromium su Windows)
"--hidden-import", "webview", "--hidden-import", "webview",
"--hidden-import", "webview.platforms.edgechromium", "--hidden-import", "webview.platforms.edgechromium",
"--hidden-import", "webview.platforms.winforms",
"--hidden-import", "clr", "--hidden-import", "clr",
"--hidden-import", "clr_loader",
"--hidden-import", "clr_loader.netfx",
"--hidden-import", "clr_loader.types",
"--hidden-import", "pythonnet",
"--hidden-import", "requests", "--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", "webview",
"--collect-all", "pythonnet",
"--collect-all", "clr_loader",
"--collect-all", "mutagen", "--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 # Binari bundled
*add_binaries, *add_binaries,
# Entry point # Entry point
+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.13"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
+31 -34
View File
@@ -33,11 +33,18 @@ def _bundle_dirs() -> list[Path]:
# Directory dell'eseguibile # Directory dell'eseguibile
exe_dir = Path(sys.executable).parent exe_dir = Path(sys.executable).parent
dirs.append(exe_dir) dirs.append(exe_dir)
# macOS: Contents/Frameworks/ (PyInstaller onedir .app) # macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle
# ffmpeg.app / ffprobe.app dove sono incapsulati per TCC.
if not _IS_WINDOWS: if not _IS_WINDOWS:
frameworks = exe_dir.parent / "Frameworks" frameworks = exe_dir.parent / "Frameworks"
if frameworks.exists(): if frameworks.exists():
dirs.append(frameworks) dirs.append(frameworks)
# Mini-bundle .app per i subprocess (vedi build_macos.py
# wrap_subprocess_in_bundle).
for sub in ("ffmpeg.app", "ffprobe.app"):
sub_macos = frameworks / sub / "Contents" / "MacOS"
if sub_macos.exists():
dirs.append(sub_macos)
return dirs return dirs
@@ -96,55 +103,45 @@ def find_ytdlp() -> str:
raise FileNotFoundError(msg) raise FileNotFoundError(msg)
def find_ffmpeg_dir() -> Optional[str]: def _find_binary(name: str) -> Optional[str]:
"""Trova la directory contenente ffmpeg e ffprobe. """Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
exe_name = _exe(name)
Ordine di ricerca: # 1. Bundle (incluso eventuali sub-bundle .app su macOS)
1. Bundle PyInstaller
2. Percorsi noti per OS
3. Qualsiasi posizione nel PATH
"""
ffmpeg_name = _exe("ffmpeg")
# 1. Bundle
for d in _bundle_dirs(): for d in _bundle_dirs():
if (d / ffmpeg_name).exists(): candidate = d / exe_name
return str(d) if candidate.exists():
return str(candidate)
# 2. Percorsi noti # 2. Percorsi noti per OS
if _IS_WINDOWS: if _IS_WINDOWS:
# Posizioni comuni su Windows
for search in ( for search in (
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin", Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
Path("C:/ffmpeg/bin"), Path("C:/ffmpeg/bin"),
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin", Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
): ):
if (search / "ffmpeg.exe").exists(): cand = search / exe_name
return str(search) if cand.exists():
return str(cand)
else: else:
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"): for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
if (Path(search_path) / "ffmpeg").exists(): cand = Path(search_path) / exe_name
return search_path if cand.exists():
return str(cand)
# 3. PATH generico # 3. PATH generico
ffmpeg = shutil.which("ffmpeg") found = shutil.which(name)
if ffmpeg: return found if found else None
return str(Path(ffmpeg).parent)
return None
def find_ffmpeg_dir() -> Optional[str]:
"""Ritorna la directory contenente ffmpeg (utile per PATH env)."""
ff = _find_binary("ffmpeg")
return str(Path(ff).parent) if ff else None
def find_ffmpeg() -> Optional[str]: def find_ffmpeg() -> Optional[str]:
"""Ritorna il path completo di ffmpeg.""" """Ritorna il path completo di ffmpeg."""
d = find_ffmpeg_dir() return _find_binary("ffmpeg")
if d:
return str(Path(d) / _exe("ffmpeg"))
return None
def find_ffprobe() -> Optional[str]: def find_ffprobe() -> Optional[str]:
"""Ritorna il path completo di ffprobe.""" """Ritorna il path completo di ffprobe."""
d = find_ffmpeg_dir() return _find_binary("ffprobe")
if d:
return str(Path(d) / _exe("ffprobe"))
return None
+14 -2
View File
@@ -73,7 +73,14 @@ def _is_virtual(name: str) -> bool:
def _list_macos(ffmpeg: str) -> list[dict]: def _list_macos(ffmpeg: str) -> list[dict]:
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`. """Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
L'output va su stderr.""" L'output va su stderr.
IMPORTANTE: come 'id' usiamo il NOME del device (non l'index numerico).
AVFoundation rinumera silenziosamente i device quando cambia il set di
hardware collegato (es. iPhone in continuita', Multi-Output Device, ecc).
Il nome e' invece stabile; ffmpeg AVFoundation accetta sia ':<index>'
sia ':<name>' come specifica del device input.
"""
try: try:
proc = subprocess.run( proc = subprocess.run(
[ffmpeg, "-hide_banner", "-f", "avfoundation", [ffmpeg, "-hide_banner", "-f", "avfoundation",
@@ -105,7 +112,12 @@ def _list_macos(ffmpeg: str) -> list[dict]:
if not m: if not m:
continue continue
idx, name = m.group(1), m.group(2).strip() idx, name = m.group(1), m.group(2).strip()
devices.append({"id": idx, "name": name, "is_virtual": _is_virtual(name)}) devices.append({
"id": name, # nome stabile (vedi docstring)
"index": idx, # solo informativo per debug
"name": name,
"is_virtual": _is_virtual(name),
})
return devices return devices
+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]
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; 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 { .nav-links {
display: flex; display: flex;
gap: 26px; gap: 26px;
@@ -601,6 +608,69 @@ a { color: inherit; text-decoration: none; }
text-align: center; 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 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 { 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-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> </style>
</head> </head>
<body> <body>
@@ -137,7 +179,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,19 +188,30 @@
<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>
<li>Apri il file <code>.zip</code> scaricato — viene estratto in <code>MusicTools.app</code></li> <li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
<li>Trascina <strong>MusicTools.app</strong> nella cartella <strong>Applicazioni</strong></li> <li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</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>
<li>Inserisci email e chiave per attivare</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> </ol>
</div> </div>
@@ -184,19 +237,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 +285,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
+64 -5
View File
@@ -11,7 +11,9 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://musictools.djluza.com" /> <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" /> <link rel="stylesheet" href="css/style.css" />
</head> </head>
<body> <body>
@@ -20,7 +22,7 @@
<header class="nav"> <header class="nav">
<div class="container nav-row"> <div class="container nav-row">
<a href="#top" class="brand"> <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> <span class="brand-name">MusicTools</span>
</a> </a>
<nav class="nav-links"> <nav class="nav-links">
@@ -284,6 +286,41 @@
</p> </p>
</div> </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"> <div class="use-policy">
<h3>Uso responsabile</h3> <h3>Uso responsabile</h3>
<p> <p>
@@ -343,8 +380,30 @@
</details> </details>
<details class="faq-item"> <details class="faq-item">
<summary>Devo creare un account Spotify Premium o YouTube Premium?</summary> <summary>Mi serve un abbonamento Spotify 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> <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>
<details class="faq-item"> <details class="faq-item">
@@ -403,7 +462,7 @@
<footer class="footer"> <footer class="footer">
<div class="container footer-row"> <div class="container footer-row">
<div class="footer-brand"> <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>MusicTools</span>
<span class="footer-by">by LuZa</span> <span class="footer-by">by LuZa</span>
</div> </div>
+9 -7
View File
@@ -21,7 +21,7 @@
<main class="legal-doc"> <main class="legal-doc">
<h1>Informativa Privacy</h1> <h1>Informativa Privacy</h1>
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p> <p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
<h2>1. Titolare del trattamento</h2> <h2>1. Titolare del trattamento</h2>
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p> <p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
@@ -30,24 +30,26 @@
<ul> <ul>
<li><strong>Email</strong>: associata all'acquisto. Usata per inviare la chiave di licenza, comunicazioni di servizio e supporto.</li> <li><strong>Email</strong>: associata all'acquisto. Usata per inviare la chiave di licenza, comunicazioni di servizio e supporto.</li>
<li><strong>Chiave di licenza, device_id, nome dispositivo, versione app</strong>: salvati nel database licenze per gestire le attivazioni (max 3 dispositivi) e impedire abusi.</li> <li><strong>Chiave di licenza, device_id, nome dispositivo, versione app</strong>: salvati nel database licenze per gestire le attivazioni (max 3 dispositivi) e impedire abusi.</li>
<li><strong>Piano sottoscritto, id ordine e id sottoscrizione Lemon Squeezy, data scadenza/rinnovo</strong>: necessari per applicare correttamente le funzioni del piano (Basic/Pro/Premium/Annual) e gestire rinnovi, disdette, rimborsi.</li>
<li><strong>Contatore download giornaliero</strong>: numero aggregato di download effettuati ogni giorno (azzerato a mezzanotte ora di Roma). Serve esclusivamente ad applicare i limiti del piano (10/30/150 al giorno). Non vengono registrati URL ne' titoli dei brani scaricati.</li>
<li><strong>Dati di pagamento</strong>: gestiti esclusivamente da Lemon Squeezy Inc. L'Editore non vede ne' conserva i dati della carta.</li> <li><strong>Dati di pagamento</strong>: gestiti esclusivamente da Lemon Squeezy Inc. L'Editore non vede ne' conserva i dati della carta.</li>
</ul> </ul>
<h2>3. Finalita' e base giuridica</h2> <h2>3. Finalita' e base giuridica</h2>
<ul> <ul>
<li>Esecuzione del contratto (consegna della licenza, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li> <li>Esecuzione del contratto (consegna della licenza, gestione del piano sottoscritto, rinnovi, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li> <li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
<li>Sicurezza del software (verifica anti-abuso delle attivazioni) — interesse legittimo, art. 6(1)(f) GDPR.</li> <li>Sicurezza del software e applicazione dei limiti del piano (verifica anti-abuso delle attivazioni, contatore giornaliero) — interesse legittimo, art. 6(1)(f) GDPR.</li>
</ul> </ul>
<h2>4. Conservazione</h2> <h2>4. Conservazione</h2>
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni.</p> <p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni. Il contatore download giornaliero viene mantenuto per 90 giorni e poi cancellato.</p>
<h2>5. Terze parti</h2> <h2>5. Terze parti</h2>
<ul> <ul>
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento.</li> <li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento, gestione abbonamenti.</li>
<li><strong>Cloudflare Inc.</strong> — hosting infrastruttura backend (Workers, D1, R2, Pages).</li> <li><strong>Resend Inc.</strong> — invio email transazionali (consegna chiave di licenza, comunicazioni di servizio).</li>
<li><strong>Resend Inc.</strong> — invio email transazionali.</li> <li><strong>Provider di hosting self-managed</strong> — i dati di licenza e il backend API sono ospitati su server gestiti dall'Editore. I dati non vengono condivisi con altri fornitori cloud.</li>
</ul> </ul>
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p> <p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
+16 -5
View File
@@ -21,20 +21,31 @@
<main class="legal-doc"> <main class="legal-doc">
<h1>Politica rimborsi</h1> <h1>Politica rimborsi</h1>
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p> <p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
<h2>Garanzia 14 giorni</h2> <h2>Garanzia 14 giorni sul primo acquisto</h2>
<p>Hai 14 giorni di tempo dall'acquisto per chiedere il rimborso completo, senza fornire motivazioni. La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p> <p>Hai <strong>14 giorni</strong> di tempo dal <strong>primo acquisto</strong> per chiedere il rimborso completo, senza fornire motivazioni. Vale per tutti i piani: Basic, Pro, Premium e Annual.</p>
<p>La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
<h2>Cosa succede dopo</h2> <h2>Differenze per piano</h2>
<ul>
<li><strong>Basic / Pro / Premium</strong> (mensili): puoi disdire l'abbonamento <strong>in qualsiasi momento</strong> dalla dashboard di Lemon Squeezy o scrivendoci. La disdetta interrompe il rinnovo successivo; la licenza resta attiva fino alla scadenza del periodo gia pagato. Il rimborso integrale e' previsto solo entro 14 giorni dal primo addebito. <strong>I rinnovi successivi al primo non sono rimborsabili</strong> ma sono interrompibili con disdetta.</li>
<li><strong>Annual</strong> (una tantum, 49,90 € per 12 mesi): rimborso integrale entro 14 giorni dall'acquisto. Oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</li>
</ul>
<h2>Cosa succede dopo la richiesta</h2>
<ol> <ol>
<li>Riceverai una risposta entro 48 ore lavorative.</li> <li>Riceverai una risposta entro 48 ore lavorative.</li>
<li>Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento usato per l'acquisto. Tempi tipici: 5-10 giorni lavorativi.</li> <li>Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento usato per l'acquisto. Tempi tipici: 5-10 giorni lavorativi.</li>
<li>La tua chiave di licenza viene revocata. L'app smette di funzionare al successivo controllo online.</li> <li>La tua chiave di licenza viene revocata. L'app smette di funzionare al successivo controllo online.</li>
</ol> </ol>
<h2>Come disdire un abbonamento mensile</h2>
<p>Se sei oltre i 14 giorni e vuoi solo non rinnovare, accedi alla tua dashboard Lemon Squeezy (link nella ricevuta) e clicca "Cancel subscription". In alternativa scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a> e la disattiviamo per te.</p>
<h2>Limitazioni</h2> <h2>Limitazioni</h2>
<p>Trascorsi 14 giorni dall'acquisto non e' piu' possibile chiedere il rimborso, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p> <p>Trascorsi 14 giorni dal primo acquisto non e' piu' possibile chiedere il rimborso integrale, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
<p>Spotify potrebbe modificare in qualsiasi momento le proprie API o le condizioni di accesso (es. richiedere un account Premium per generare le credenziali). Tali modifiche di terze parti non costituiscono titolo per il rimborso del piano MusicTools acquistato. La sezione "Esclusione di garanzie" dei <a href="/legal/terms.html">Termini di servizio</a> si applica integralmente.</p>
<h2>Domande?</h2> <h2>Domande?</h2>
<p>Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>: siamo persone vere, rispondiamo entro un giorno lavorativo.</p> <p>Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>: siamo persone vere, rispondiamo entro un giorno lavorativo.</p>
+16 -4
View File
@@ -38,7 +38,7 @@
<main class="legal-doc"> <main class="legal-doc">
<h1>Termini di servizio e Licenza d'uso</h1> <h1>Termini di servizio e Licenza d'uso</h1>
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p> <p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
<div class="warn-box"> <div class="warn-box">
<p><strong>Leggi attentamente.</strong> Acquistando, installando o usando MusicTools accetti i presenti Termini. Se non li accetti, non installare ne' usare il software.</p> <p><strong>Leggi attentamente.</strong> Acquistando, installando o usando MusicTools accetti i presenti Termini. Se non li accetti, non installare ne' usare il software.</p>
@@ -163,6 +163,7 @@
<li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li> <li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li>
</ul> </ul>
<p>Le piattaforme di origine (Spotify, YouTube, ecc.) possono modificare in qualsiasi momento le proprie API, formati o politiche, rendendo il Software temporaneamente o permanentemente inutilizzabile per certe funzioni. L'Editore non garantisce alcuna continuita' funzionale.</p> <p>Le piattaforme di origine (Spotify, YouTube, ecc.) possono modificare in qualsiasi momento le proprie API, formati o politiche, rendendo il Software temporaneamente o permanentemente inutilizzabile per certe funzioni. L'Editore non garantisce alcuna continuita' funzionale.</p>
<p><strong>Credenziali Spotify Web API:</strong> per scaricare playlist e album interi da Spotify il Software richiede che l'Utente generi proprie chiavi API (Client ID e Client Secret) dalla Spotify Developer Dashboard e le configuri all'interno del Software. Spotify, in seguito ad aggiornamenti della propria policy, consente la creazione di tali credenziali esclusivamente agli utenti con <strong>account Spotify Premium</strong> attivo. L'Editore non e' in alcun modo affiliato a Spotify, non fornisce, vende o rivende account Spotify, non puo' garantire l'accessibilita' o il funzionamento delle API Spotify per gli account Free, e non e' responsabile per limitazioni, sospensioni o ban applicati da Spotify alle credenziali dell'Utente. I download di singoli brani da Spotify, basati su metadati pubblici, non richiedono credenziali API ne' un abbonamento Premium.</p>
<h2 id="s10">10. Limitazione di responsabilita</h2> <h2 id="s10">10. Limitazione di responsabilita</h2>
<p>Nei limiti massimi consentiti dalla legge applicabile, in nessun caso l'Editore sara' responsabile verso l'Utente o verso terzi per:</p> <p>Nei limiti massimi consentiti dalla legge applicabile, in nessun caso l'Editore sara' responsabile verso l'Utente o verso terzi per:</p>
@@ -185,14 +186,25 @@
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li> <li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
</ul> </ul>
<h2 id="s12">12. Pagamenti e fatturazione</h2> <h2 id="s12">12. Piani, prezzi e fatturazione</h2>
<p>I pagamenti sono gestiti da <strong>Lemon Squeezy Inc.</strong>, che opera come Merchant of Record (MoR). Lemon Squeezy emette la ricevuta/fattura fiscale, gestisce l'IVA secondo la giurisdizione del cliente (regime MOSS UE) e fornisce assistenza per problematiche di pagamento.</p> <p>I pagamenti sono gestiti da <strong>Lemon Squeezy Inc.</strong>, che opera come Merchant of Record (MoR). Lemon Squeezy emette la ricevuta/fattura fiscale, gestisce l'IVA secondo la giurisdizione del cliente (regime MOSS UE) e fornisce assistenza per problematiche di pagamento.</p>
<p>Prezzo pubblicato: <strong>39,90 € una tantum</strong> (IVA inclusa secondo paese di residenza dell'Utente).</p> <p>Il Software e' commercializzato in quattro piani, ciascuno con prezzi indicati IVA inclusa secondo paese di residenza dell'Utente:</p>
<ul>
<li><strong>Basic</strong> &mdash; 2,99 € / mese (abbonamento). Include il download di brani audio fino a 10 al giorno; non include video, registrazione, editor metadati, upgrade libreria.</li>
<li><strong>Pro</strong> &mdash; 5,99 € / mese (abbonamento). Include audio + video + registrazione + editor metadati + upgrade libreria, fino a 30 download al giorno.</li>
<li><strong>Premium</strong> &mdash; 9,99 € / mese (abbonamento). Stesse funzioni del Pro con limite di 150 download al giorno.</li>
<li><strong>Annual</strong> &mdash; 49,90 € una tantum. Tutte le funzioni sbloccate, nessun limite giornaliero, validita' 12 (dodici) mesi dalla data di acquisto. Non si rinnova automaticamente.</li>
</ul>
<p><strong>Limiti giornalieri:</strong> il contatore si azzera ogni giorno a mezzanotte ora di Roma (Europe/Rome). I download non utilizzati non si cumulano.</p>
<p><strong>Abbonamenti mensili (Basic, Pro, Premium):</strong> si rinnovano automaticamente alla data di addebito mensile fino a disdetta dell'Utente, che puo' essere effettuata in qualsiasi momento dalla dashboard Lemon Squeezy o scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a>. La disdetta interrompe il rinnovo successivo; la Licenza resta attiva fino alla scadenza del periodo gia pagato.</p>
<p><strong>Piano Annual (una tantum):</strong> il pagamento e' singolo e copre 12 mesi consecutivi. Allo scadere, la Licenza diventa inattiva e per continuare a utilizzare il Software l'Utente puo' acquistare nuovamente un piano a sua scelta.</p>
<p>L'Utente puo' passare a un piano superiore (upgrade) o inferiore (downgrade) in qualsiasi momento. Il prorata viene gestito secondo le regole di Lemon Squeezy.</p>
<h2 id="s13">13. Diritto di recesso e rimborsi</h2> <h2 id="s13">13. Diritto di recesso e rimborsi</h2>
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dall'acquisto, senza fornire motivazioni.</p> <p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dal primo acquisto, senza fornire motivazioni. Il diritto di recesso si applica al primo addebito; non si applica ai rinnovi successivi di un abbonamento mensile, per i quali resta possibile la disdetta come previsto al §12.</p>
<p>La richiesta va inviata a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto. Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento; tempi tipici 5-10 giorni lavorativi.</p> <p>La richiesta va inviata a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto. Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento; tempi tipici 5-10 giorni lavorativi.</p>
<p>A seguito del rimborso, la Licenza viene revocata e il Software smettera' di funzionare al successivo controllo online.</p> <p>A seguito del rimborso, la Licenza viene revocata e il Software smettera' di funzionare al successivo controllo online.</p>
<p>L'Utente non puo' richiedere il rimborso dell'importo gia pagato per i mesi di abbonamento gia consumati. Il piano Annual, in caso di recesso entro 14 giorni, viene rimborsato interamente; oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</p>
<h2 id="s14">14. Aggiornamenti e supporto</h2> <h2 id="s14">14. Aggiornamenti e supporto</h2>
<p>L'Editore si impegna a mantenere il Software funzionante e a rilasciare aggiornamenti gratuiti per tutta la durata commerciale del prodotto. Non e' garantito un calendario specifico di rilascio.</p> <p>L'Editore si impegna a mantenere il Software funzionante e a rilasciare aggiornamenti gratuiti per tutta la durata commerciale del prodotto. Non e' garantito un calendario specifico di rilascio.</p>
+40
View File
@@ -4,6 +4,46 @@ import os
import sys import sys
from pathlib import Path 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 import webview
from api.bridge import Api 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-core>=10.0 ; sys_platform == "darwin"
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin" pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
pyobjc-framework-WebKit>=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 planCode = planByVariantId(variantId);
const plan = planCode ? getPlan(planCode) : null; const plan = planCode ? getPlan(planCode) : null;
// Subscriptions: il record vero arriva con subscription_created. // Se l'order e' gia stato processato (lo riconosciamo dall'order_id),
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione // non duplichiamo licenza ne' email — ack 200 e basta.
// non ancora generata), accettiamo l'evento ma non creiamo nulla. const existing = await one(
if (plan && plan.is_subscription) { "SELECT id FROM licenses WHERE order_id=? LIMIT 1",
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } }; [orderId],
);
if (existing) {
return { status: 200, body: { ok: true, duplicate: true } };
} }
// Annual one-time (o piano non mappato -> trattalo come annual di default // Piano non mappato -> fallback annual cosi' non blocchiamo l'utente
// cosi' non lasciamo l'utente senza licenza se ha pagato). // che ha gia pagato (la licenza sara' usabile anche se non sappiamo
// i limiti esatti del piano comprato).
const planForRecord = plan?.code || "annual"; const planForRecord = plan?.code || "annual";
const limit = plan?.daily_limit ?? null; const planMeta = plan || getPlan("annual");
const expiresAt = t + ANNUAL_TTL_SECONDS; 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(); const key = generateLicenseKey();
try { try {
@@ -103,7 +116,10 @@ async function handleOrderCreated(payload, attrs, t) {
} }
try { try {
await sendLicenseEmail(email, key, { plan: planForRecord, expiresAt }); await sendLicenseEmail(email, key, {
plan: planForRecord,
expiresAt: expiresAt,
});
} catch (e) { } catch (e) {
console.error("[email] send failed:", e?.message || e); console.error("[email] send failed:", e?.message || e);
} }
@@ -141,7 +157,7 @@ async function handleSubscriptionCreated(payload, attrs, t) {
const key = generateLicenseKey(); const key = generateLicenseKey();
// Idempotenza: se ricevo lo stesso subscription_created due volte, // 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( const existing = await one(
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1", "SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
[subId], [subId],
@@ -156,6 +172,29 @@ async function handleSubscriptionCreated(payload, attrs, t) {
return { status: 200, body: { ok: true, duplicate: true } }; 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 { try {
await exec( await exec(
`INSERT INTO licenses `INSERT INTO licenses
+7 -1
View File
@@ -89,7 +89,13 @@ export async function download(req, res) {
} }
const name = path.basename(abs); 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}"`); res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
fs.createReadStream(abs).pipe(res); fs.createReadStream(abs).pipe(res);
} }
+47 -3
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;
@@ -841,11 +873,23 @@ input[type="number"]::-webkit-inner-spin-button {
white-space: pre-line; 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 { .save-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; 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 { .save-status {
@@ -1494,13 +1538,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;