Compare commits

...
45 Commits
Author SHA1 Message Date
luzadev dc6ae1fbe1 v1.7.15: patch_info_plist con plistlib (PlistBuddy falliva su apostrofi)
Crash report v1.7.14 ha rivelato che NSMicrophoneUsageDescription
NON era stato scritto nell'Info.plist distribuito. Causa: il valore
contiene l'apostrofo "l'audio" che rompe PlistBuddy con quote
shell. PlistBuddy falliva ma restituiva exit code != 0 con un
messaggio diverso da "already exists", quindi anche il fallback
Set non veniva chiamato -> chiave mai aggiunta.

Riscritto patch_info_plist con plistlib di Python: nessun shell
escaping, robusto a qualsiasi carattere. Aggiunto check di verifica
finale che logga errore critico se la chiave manca dopo write.
2026-06-09 23:17:08 +02:00
luzadev 0e14d161d0 v1.7.14: fix definitivo registrazione macOS — AVCaptureSession in-process
Dopo che codesign --info-plist e wrap in sub-bundle .app si sono
rivelati strade morte (macOS 14+ TCC e' troppo severo per ad-hoc
signed binaries), riscritta la registrazione macOS per usare
PyObjC + AVCaptureSession nel processo Python principale.

Il main MusicTools ha il TCC del bundle (NSMicrophoneUsageDescription
nell'Info.plist), e usando AVFoundation IN-PROCESS il permesso
viene applicato correttamente. ffmpeg subprocess viene usato SOLO
per la conversione CAF -> MP3 dopo lo stop (operazione su file,
niente accesso microfono = niente TCC).

Aggiunto pyobjc-framework-AVFoundation a requirements.
core/paths.py: ripristinato lookup semplice in Contents/Frameworks/
(non serve piu' cercare in sub-bundle).
build_macos.py: wrap_subprocess_in_bundle disabilitato.

Diagnosi crash report v1.7.13 + investigazione TCC behavior
confermano: PyObjC AVCaptureSession in-process e' l'unica
soluzione affidabile senza Developer ID Apple.

Vedi: https://www.qt.io/blog/the-curious-case-of-the-responsible-process
2026-06-09 23:04:16 +02:00
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
luzadev 61459099f9 v1.7.0: piani Basic/Pro/Premium/Annual + quota giornaliera
Pricing model: subscription mensili (Basic 2.99 / Pro 5.99 / Premium 9.99)
piu' Annual 49.90 one-time. Feature gating per piano (Basic = solo audio,
Pro+ = tutto). Quota giornaliera (10/30/150) con reset mezzanotte Europe/Rome.

Server: tabella daily_usage, endpoint /api/usage/consume e /status,
webhook LS gestisce subscription_* events + mappatura variant_id -> plan
via .env (LS_VARIANT_BASIC|PRO|PREMIUM|ANNUAL).

Client: gating tab in sidebar in base ai features del piano, contatore
"X/Y oggi" + bottone Upgrade, modal blocco con CTA verso landing.
2026-06-09 11:02:42 +02:00
luzadevandClaude Opus 4.7 bcd4f22b1e Asset brand + pagina download + 1-click download endpoint
- assets/: script generate_logo.py + generate_ls_images.py per produrre
  loghi (mark/horizontal light+dark/stacked light+dark) e immagini
  prodotto Lemon Squeezy (thumbnail 1024x1024, cover 1920x1080,
  OG 1200x630). Riproducibili: rilanciare gli script per rigenerare.
- landing/download.html: pagina con form email+chiave o autofill da
  query string ?key=&email= (link 1-click dall'email). Bottoni
  Mac/Windows + istruzioni installazione per OS.
- landing/index.html: link Download in nav, hero ("Hai gia
  acquistato?"), pricing card ("Hai gia una licenza?"), footer.
- landing/js/main.js: rimosso warning placeholder LS (URL ora reale).
- server/src/downloads.js: endpoint GET /api/download/:platform
  che verifica (key, email) contro DB e redirect a URL firmato del
  binario. Errori chiari (400/401/403/503) leggibili dall'utente.
- server/src/email.js: template Resend con 2 bottoni 1-click
  (link con key+email gia compilati).
- server/src/server.js: route aggiunta.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:29:48 +02:00
luzadevandClaude Opus 4.7 8272985459 VERSION bump v1.5.2 -> v1.6.0
Prima release con sistema licenze + landing musictools.djluza.com.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:29:09 +02:00
luzadevandClaude Opus 4.7 f339197861 Registra: messaggi di errore azionabili + log tecnici espandibili
Sul sistema dell'utente il comando ffmpeg che usa l'app funziona
correttamente (testato come subprocess identico), ma il vecchio
reporting era confuso quando l'errore era transitorio (BlackHole
non pronto, clock drift, ecc.) o quando la parola 'abort' non
compariva nello stderr.

Cambiamenti:

core/recorder.py:
- _extract_friendly_error riconosce ora piu casi: input/output error
  (BlackHole non pronto -> suggerisce 'sudo killall coreaudiod'),
  device not configured, permission denied, no such device
- rc=-6 su macOS ritorna SEMPRE il messaggio sul permesso Microfono
  (prima richiedeva 'abort' nello stderr, non sempre presente)
- Fallback finale mostra anche le ultime 3 righe di stderr
- Nuova funzione get_last_stderr() per debugging

api/bridge.py:
- Nuovo metodo get_recorder_log() che ritorna stderr per la UI

webui:
- Nuova card di errore persistente con bottoni
  'Mostra log tecnici' / 'Copia log'
- Si resetta automaticamente all'avvio di una nuova registrazione

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:08:11 +02:00
luzadevandClaude Opus 4.7 2d8290579b Riscrivi backend per hosting Node/MariaDB/PM2 (no Cloudflare)
L'utente ha un hosting Virtualmin esistente (musictools.djluza.com)
con Apache + Node 20 + MariaDB + PM2. Tutta l'infrastruttura
Cloudflare (Workers/D1/R2/Pages) e' rimpiazzata con un backend
Express che gira sul server gia' presente, a costo zero.

Cambia:
- server/src/*.ts (Workers) -> server/src/*.js (Node ESM puro)
- D1 (sqlite) -> MariaDB 10.11 via mysql2/promise
- R2 (storage) -> filesystem locale ~/builds/ + signed URL HMAC
- wrangler.toml -> ecosystem.config.cjs (PM2)
- Aggiunto src/migrate.js: runner SQL idempotente

Endpoints invariati - l'app desktop non vede differenze:
- POST /api/license/{activate,validate,deactivate}
- GET  /api/latest, /api/download
- POST /api/webhook/lemonsqueezy
- GET  /api/health

README riscritto con istruzioni complete: creazione DB,
deploy via rsync/git, config Apache reverse proxy via
Virtualmin, gestione PM2, backup, workflow release.

Email rimane su Resend (deliverability) - free tier sufficiente.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 00:15:53 +02:00
luzadevandClaude Opus 4.7 339dd4f3cc Foro competente: Tribunale di Chieti (residenza Ortona, CH)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:57:56 +02:00
luzadevandClaude Opus 4.7 4e917fd9ef Rafforza Termini: EULA, Acceptable Use, manleva, disclaimer copyright
- terms.html: riscritto da 9 a 18 sezioni con indice cliccabile.
  Include EULA (concessione, restrizioni, IP), Acceptable Use Policy
  (cosa si puo'/non si puo' fare con i Contenuti), disclaimer marchi
  terze parti (Spotify/YouTube/...), procedura DMCA, esclusione
  garanzie AS IS, cap responsabilita = importo pagato negli ultimi
  12 mesi, clausola di manleva (indemnification) a carico dell'utente.
- index.html: nuova box "Uso responsabile" sotto al pricing con i 4
  punti chiave; FAQ ampliate (no affiliazione, segnalazioni copyright);
  testo di accettazione esplicito Termini+Privacy+Refund sotto al CTA;
  footer con titolarita "Luciano Zappacosta".
- css/style.css: stili per use-policy box e price-consent.

Linee guida prese da boilerplate di settore (4K Video Downloader,
JDownloader, MediaHuman) adattate al diritto italiano e GDPR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:56:17 +02:00
luzadevandClaude Opus 4.7 729f3cd8a0 Fix nome titolare nei documenti legali: Luciano Zappacosta
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:52:11 +02:00
luzadevandClaude Opus 4.7 4ebfbd472e Landing page per musictools.djluza.com (Cloudflare Pages)
- index.html: hero con mockup app, 6 feature cards, come funziona,
  pricing 39,90 EUR, FAQ accordion, footer
- css/style.css: tema dark Spotify-like coerente con l'app
- js/main.js: animazioni scroll, hook Lemon Squeezy overlay
- legal/{terms,privacy,refund}.html: documenti richiesti da LS/GDPR
- _headers: HSTS, no-sniff, cache lunga per asset
- _redirects: placeholder per futuri /download/{macos,windows}
- README con istruzioni deploy push-to-deploy via Cloudflare Pages

Da fare prima del lancio: sostituire REPLACE_WITH_LS_PRODUCT_URL
in index.html con l'URL del prodotto Lemon Squeezy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:49:58 +02:00
luzadevandClaude Opus 4.7 5202b2c586 Setup commercial launch: licenze + backend Cloudflare Worker
Client (Python/JS):
- core/license.py: attivazione, validazione, deactivate (HTTP client + JWT decode)
- core/config.py: campi license_* + URL endpoint + costanti grace/revalidate
- api/bridge.py: get_init_data ritorna license status; activate_license/
  deactivate_license/revalidate_license/open_purchase_page
- check_update riscritto: punta a musictools.djluza.com/api/latest con
  Bearer token, URL di download firmato dal server solo se licensed
- webui: schermata di attivazione bloccante all'avvio; sezione Licenza
  in Impostazioni con verifica/disattiva

Backend (server/):
- Cloudflare Worker + D1 + R2 (vedi server/README.md)
- Endpoints: /api/license/{activate,validate,deactivate}, /api/latest,
  /api/webhook/lemonsqueezy, /api/health
- JWT HS256 con rotazione; max 3 attivazioni/licenza
- Generazione licenze via webhook Lemon Squeezy + email Resend
- Schema D1 in migrations/0001_init.sql

Memory: nuova musictools-commercial-launch.md, rimosso pending obsoleto

NON taggare finche backend non e' deployato (l'app e' bloccante)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:36:41 +02:00
luzadevandClaude Opus 4.7 05a2a58ace Tab Scarica: ricerca diretta per titolo o "Artista - Titolo"
L'input URL ora accetta anche query libere: se non sembra un link
(http/https/spotify:/dominio noto), il testo viene parsato come
{artist, name} e scaricato via download_playlist con una traccia.

Riusa lo stesso flusso ytsearch1: gia in uso per le tracklist da file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:13:10 +02:00
luzadevandClaude Opus 4.7 8610f4d95c Fix Windows: nascondi finestra cmd per ogni subprocess yt-dlp/ffmpeg
Aggiunto helper subprocess_flags() in core/paths.py che ritorna
CREATE_NO_WINDOW su Windows. Applicato a tutte le subprocess.run/Popen
in downloader, upgrader e recorder per evitare il flash di console nera
ad ogni traccia scaricata.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:06:59 +02:00
luzadevandClaude Opus 4.7 b47fdd1ab3 VERSION bump v1.4.0 -> v1.5.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:36:58 +02:00
luzadevandClaude Opus 4.7 e56a975b77 Nuova tab Guida: manuale d'uso completo dentro l'app
Aggiunta una sezione 'Guida' (icona 📖, hero indaco) con manuale
d'uso interno, organizzato in 10 capitoli con sommario sticky a
sinistra:

- Introduzione, Setup iniziale
- Una sezione per ogni tab (Scarica, Video, Upgrade, Metadati,
  Registra, Impostazioni)
- Aggiornamenti
- Troubleshooting con tabella di 8 sintomi/cause/fix
- Risorse esterne (GitHub, yt-dlp FAQ, BlackHole)

UX:
- Layout grid 200px (TOC) + content fluido. Su schermi <900px
  il TOC diventa una row in alto
- TOC sticky con highlight del link attivo via IntersectionObserver
- Link esterni (data-ext) aperti nel browser di sistema via
  open_external_url
- Hint nella tab Registra ora apre la sezione 'Tab Registra' della
  guida invece del README su GitHub
- Box info/success/warn riusabili, stile <code> verde, <pre>
  monospace, tabella troubleshooting

CSS:
- Nuova palette indigo + hero-indigo
- Stili .guide-layout, .guide-toc, .g-section, .g-box, .g-table,
  .kbd, .toc-link.active

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:30:48 +02:00
luzadevandClaude Opus 4.7 52002f5184 Icona dell'app: squircle verde con nota musicale
Creata un'icona coerente con la palette dell'app: squircle Apple-style
(corner radius ~22%), gradient verde Spotify -> verde scuro, nota
musicale (♫) bianca centrata con leggero glow, sottile highlight in
alto e bordo soft per definizione.

- assets/generate_icon.py: script PIL riproducibile, produce
  icon.png (master 1024) -> icon.iconset/ -> iconutil -> icon.icns
  (macOS) e icon.ico multi-size (Windows)
- assets/icon.png / icon.icns / icon.ico committati
- assets/icon.iconset/ in .gitignore (intermedio rigenerabile)
- build_macos.py: --icon assets/icon.icns se presente
- build_windows.py: --icon assets/icon.ico se presente

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:26:06 +02:00
luzadevandClaude Opus 4.7 878e024c13 Rimuovi playlist personali dal repo
Tolgo dal tracking i file di playlist personali presenti nel
working tree fin dai primi commit:

- Beatport Top 100 Melodic House & Techno.txt
- PlayList_2026.txt
- PlayList_da_scaricare.txt
- Vibes_List.txt

Aggiunti i pattern al .gitignore cosi' non rientrano per sbaglio.
I file rimangono sul disco locale (git rm --cached, non -rf).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:19:37 +02:00
luzadevandClaude Opus 4.7 07794052c8 VERSION bump v1.2.2 -> v1.4.0
Allinea la stringa VERSION al numero del tag della prossima release.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:16:42 +02:00
luzadevandClaude Opus 4.7 850d3f4b6c Rename: l'app si chiama MusicTools (non piu MusicDownload)
- main.py: titolo finestra pywebview -> 'MusicTools'
- core/config.py: APP_NAME='MusicTools', cartella config bundled
  ora ~/Library/Application Support/MusicTools/ e %APPDATA%/MusicTools/.
  Migrazione one-shot: se non esiste la nuova ma esiste la legacy
  'MusicDownload', il config.json viene copiato cosi' le impostazioni
  non vanno perse dopo l'aggiornamento
- core/recorder.py: messaggio errore permesso microfono
- api/bridge.py: User-Agent update check + guida Spotify menzionano
  MusicTools
- build_macos.py / build_windows.py / .github/workflows/build.yml:
  prodotti rinominati (MusicTools.app, MusicTools.exe,
  MusicTools-macOS.zip, MusicTools-Windows.zip, Release 'Build MusicTools')
- webui: <title>, brand-name sidebar, intestazioni commenti
- README: tutti i riferimenti aggiornati + nota che il repo Git
  conserva il nome storico 'musicdownload' (URL stabili per
  release gia' pubblicate)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:15:34 +02:00
luzadevandClaude Opus 4.7 6f5c32165a Video tab: modalita 'Solo audio' (estrazione MP3)
Aggiunto toggle Video/Solo audio nella tab Video. In modalita audio
yt-dlp scarica solo bestaudio ed estrae in MP3 al bitrate impostato
in Impostazioni (default 320 kbps), con embed thumbnail come la tab
Scarica.

- core/downloader.py: download_video() accetta audio_only e
  audio_bitrate; quando True usa format 'bestaudio/best' e
  --extract-audio --audio-format mp3
- api/bridge.py: start_video_download passa audio_only nel worker;
  log mostra 'Solo audio MP3 320K' vs 'Video 1080p'
- webui: segmented control 'Video (MP4)' / 'Solo audio (MP3)' rosa,
  selettore qualita nascosto in modalita audio, hint aggiornato

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:11:50 +02:00
luzadevandClaude Opus 4.7 39c92f44f4 Guida registrazione: Setup A/B BlackHole + Multi-Output Device
- README: sezione 'Guida rapida' riscritta con installazione,
  permesso microfono macOS, Setup A (output dedicato a BlackHole)
  e Setup B (Multi-Output Device con BlackHole + altoparlanti,
  Master Device + Drift Correction). Aggiunta tabella
  Troubleshooting (5 sintomi tipici con fix)
- webui: hint nella tab Registra arricchito (riassunto a step
  inline + link al README su GitHub via window open)
- CSS: .hint passa a align-items flex-start per contenuto
  multi-riga, aggiunti stili per <code> e <a> dentro l'hint

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:09:26 +02:00
luzadevandClaude Opus 4.7 9f37b7eb94 Recorder: drena stderr di ffmpeg + messaggi di errore leggibili
Il vecchio worker non drenava lo stderr di ffmpeg: dopo ~64KB il
pipe si bloccava e quando ffmpeg falliva subito (es. permesso
microfono negato su macOS) si vedeva solo 'ffmpeg exit -6' senza
contesto.

- Thread dedicato _drain_stderr() che legge stderr in continuo e
  mantiene le ultime 50 righe in un buffer protetto
- _extract_friendly_error() riconosce i casi tipici e produce
  messaggi italiani:
    * exit -6 su macOS -> 'permesso Microfono mancante'
    * Input/Output error -> 'dispositivo non disponibile'
    * 'no such device' -> 'premere Aggiorna'
- Pre-flight check: aspetta che ffmpeg stampi 'Press [q]' (segnale
  di device aperto) prima di emettere 'started'. Se ffmpeg muore
  prima, l'errore viene segnalato senza falso 'recording in corso'

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:03:22 +02:00
luzadevandClaude Opus 4.7 5041d3e14c README: aggiornato con tab Metadati, Registra e guida BlackHole
- Tabella riepilogativa delle 6 tab dell'app (Scarica, Video,
  Upgrade, Metadati, Registra, Impostazioni) con descrizione
  breve di ciascuna feature
- Sezione 'Auto-skip dei duplicati' e 'Sottocartella automatica
  per le tracklist' (feature aggiunte in v1.2.x)
- Aggiunta 'mutagen' nelle dipendenze principali
- Nuova sezione 'Guida rapida: registrazione audio di sistema
  (macOS)' con istruzioni complete per installare BlackHole
  e creare un Multi-Output Device per sentire e registrare
  contemporaneamente
- Struttura progetto: aggiunti core/metadata.py e core/recorder.py
- Esempio tag nel paragrafo 'Build & Release' bumpato a v1.3.0
- Nota sull'aggiornamento app: ricorda il vincolo del repo pubblico
  per far funzionare l'API GitHub Releases

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:47:15 +02:00
luzadevandClaude Opus 4.7 97a440819a Nuova tab Registra: cattura audio da ingresso del computer
Aggiunta sezione per registrare in MP3 320 kbps da qualsiasi
dispositivo di input audio del sistema (mixer, scheda audio
esterna, microfono interno, BlackHole / driver virtuali per audio
di sistema).

- core/recorder.py: list_input_devices() parsa l'output di
  ffmpeg -list_devices su AVFoundation (macOS) e DirectShow (Win),
  marca come 'virtual' i driver loopback noti (BlackHole, Soundflower,
  Loopback, VB-Cable, Stereo Mix, OBS). start_recording() lancia
  ffmpeg in thread e emette eventi tick/started/stopped/error.
- api/bridge.py: list_audio_inputs, start_audio_recording,
  stop_audio_recording, is_audio_recording
- webui: hero rosso, dropdown dispositivi con badge 'loopback',
  selettore bitrate, timer HH:MM:SS animato, bottone REC con
  pulse animation, card 'Ultima registrazione' con durata+path
- Hint visibile: per registrare audio di sistema serve BlackHole

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:44:05 +02:00
luzadevandClaude Opus 4.7 1d7832e246 Metadati: campo 'Origine (macOS · kMDItemWhereFroms)'
kMDItemWhereFroms non e un tag ID3/MP4/Vorbis: e' un extended
attribute filesystem di macOS che Safari/Mail/AirDrop e altri tool
impostano per ricordare l'origine di un file scaricato.

- core/metadata.py: _read_where_from / _write_where_from via subprocess
  xattr + plistlib (lettura/scrittura del plist binario embedded
  nell'attributo)
- read_metadata espone 'where_from' (lista URL) e 'is_macos'
- write_metadata applica l'xattr se data['where_from'] e fornito;
  lista vuota -> rimuove l'attributo
- webui: textarea 'Origine' visibile solo su macOS (is_macos=true),
  una URL per riga

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:36:18 +02:00
luzadevandClaude Opus 4.7 61879c42d3 Metadati WAV: fallback al chunk RIFF LIST/INFO
Mutagen.wave legge solo i tag ID3 embedded nel WAV, ma molti file
WAV usano il chunk RIFF LIST/INFO originale (es. ffmpeg, vari
encoder semplici).

Aggiunto _read_wav_info_chunk() che parsa manualmente il blocco
RIFF e mappa i campi standard (INAM, IART, IPRD, ICRD, IGNR,
ICMT, ITRK, IBPM, ...) sui campi della UI.

In _read_wav() ora si legge prima il chunk INFO come fallback, poi
l'ID3 sovrascrive i valori se presenti (ID3 ha priorita per i WAV
moderni).

In scrittura si continua a usare ID3 (standard piu ricco e supportato
dai player moderni); i campi INFO esistenti restano nel file ma
diventano ridondanti.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:32:58 +02:00
luzadevandClaude Opus 4.7 246ff1b0e5 Metadati: aggiunto supporto WAV (ID3 chunk embedded)
WAV puo' contenere un blocco ID3v2 embedded oltre al PCM raw.
Mutagen.wave.WAVE espone gli stessi frame TIT2/TPE1/APIC etc.
del MP3.

- core/metadata.py: SUPPORTED_EXTS include '.wav', read/write via
  WAVE.tags (con add_tags() se mancante)
- bridge.py: file picker include *.wav nel filter audio

Round-trip verificato: titolo/artista/album/anno/track/bpm/key/
commento + copertina preservati.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:29:36 +02:00
luzadevandClaude Opus 4.7 3dcbdfa489 Fix tab Metadati: file dialog non si apriva
I file filter di pywebview vogliono estensioni separate da ';' non
da spazio. Cambiato 'Audio (*.mp3 *.m4a ...)' in
'Audio (*.mp3;*.m4a;...)'. Stesso fix per il dialog copertine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:26:15 +02:00
luzadevandClaude Opus 4.7 946452ef03 Tab Metadati: editor dei tag audio (MP3, M4A, FLAC)
Nuova sezione 'Metadati' nella sidebar con editor completo dei tag:
titolo, artista, album, album artist, anno, traccia, genere, BPM,
key, commento, copertina.

- core/metadata.py: read/write metadati via mutagen, supporto ID3
  (MP3), MP4 atoms (M4A/AAC) e Vorbis comments (FLAC)
- api/bridge.py: pick_audio_file, pick_image_file, read_metadata,
  save_metadata. Cover passata come base64 al frontend per preview
- webui: hero ambra, form a griglia 2 colonne, anteprima cover 180x180,
  bottoni cambia/rimuovi copertina, ricarica file
- requirements.txt: aggiunto mutagen>=1.47.0
- build_macos.py / build_windows.py: --collect-all mutagen

Modifiche correlate (in attesa di decisione utente sulla
distribuzione degli aggiornamenti):
- VERSION bumpato a v1.2.2 in core/config.py
- check_update ora interroga l'API GitHub Releases invece di
  djluza.com/version.json (funzionera quando il repo sara pubblico)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:24:07 +02:00
luzadevandClaude Opus 4.7 d8423ec215 Tracklist: crea sottocartella col nome del file caricato
Quando si carica un file .txt come tracklist, l'app ora scarica i
brani in <output_dir>/<nome_file>/ anziche direttamente in output_dir.
Es: 'Beatport Top 100 Melodic House & Techno.txt' -> cartella
'Beatport Top 100 Melodic House & Techno/' creata dentro la
cartella output predefinita.

- load_url_list ritorna 'name' (basename senza estensione)
- start_tracks_download accetta 'subfolder' opzionale e fa
  os.path.join con sanitizzazione dei separatori
- JS passa state.loaded.name come subfolder

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 00:23:20 +02:00
72 changed files with 9224 additions and 312 deletions

No files matched your search

+45 -16
View File
@@ -1,4 +1,4 @@
name: Build MusicDownload name: Build MusicTools
on: on:
workflow_dispatch: workflow_dispatch:
@@ -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 MusicDownload.app ../MusicDownload-macOS.zip rm -rf "$STAGING"
ls -lh ../MusicDownload-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: MusicDownload-macOS name: MusicTools-macOS
path: MusicDownload-macOS.zip path: MusicTools-macOS.dmg
retention-days: 14 retention-days: 14
# ========================================================= # =========================================================
@@ -66,17 +87,25 @@ 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: |
Compress-Archive -Path dist/MusicDownload/* -DestinationPath MusicDownload-Windows.zip -CompressionLevel Optimal Compress-Archive -Path dist/MusicTools/* -DestinationPath MusicTools-Windows.zip -CompressionLevel Optimal
Get-Item MusicDownload-Windows.zip Get-Item MusicTools-Windows.zip
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: MusicDownload-Windows name: MusicTools-Windows
path: MusicDownload-Windows.zip path: MusicTools-Windows.zip
retention-days: 14 retention-days: 14
# ========================================================= # =========================================================
@@ -90,22 +119,22 @@ jobs:
- name: Download macOS artifact - name: Download macOS artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: MusicDownload-macOS name: MusicTools-macOS
path: artifacts/macos path: artifacts/macos
- name: Download Windows artifact - name: Download Windows artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: MusicDownload-Windows name: MusicTools-Windows
path: artifacts/windows path: artifacts/windows
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
name: MusicDownload ${{ github.ref_name }} name: MusicTools ${{ github.ref_name }}
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
generate_release_notes: true generate_release_notes: true
fail_on_unmatched_files: true fail_on_unmatched_files: true
files: | files: |
artifacts/macos/MusicDownload-macOS.zip artifacts/macos/MusicTools-macOS.dmg
artifacts/windows/MusicDownload-Windows.zip artifacts/windows/MusicTools-Windows.zip
+15
View File
@@ -34,3 +34,18 @@ MUSICA/
# Claude # Claude
.claude/ .claude/
Apri con claude-social.command Apri con claude-social.command
# Playlist personali (non vanno nel repo)
Vibes_List.txt
Beatport Top 100*.txt
PlayList_2026.txt
PlayList_da_scaricare.txt
# Asset intermedi rigenerabili (vedi assets/generate_icon.py)
assets/icon.iconset/
# Backend (server/) build artifacts e secrets
server/node_modules/
server/.wrangler/
server/.dev.vars
server/dist/
-100
View File
@@ -1,100 +0,0 @@
1. Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)
2. Brunello, Vargas – Ghost Dance (Original Mix) (6:12)
3. Hardt Antoine – Raw (Original Mix) (5:25)
4. JUNO (DE) & Samm (BE) – Heart Spin (Original Mix) (5:56)
5. AIKON – To You (Original Mix) (6:12)
6. Adam Port, Keinemusik & SG Lewis – Be The One (Extended) (4:45)
7. HotLap – Recall (Extended Mix) (5:48)
8. JUNO (DE) & Samm (BE) – Muralli (Original Mix) (4:59)
9. Brunello – Science Fiction (Original Mix) (6:36)
10. Emanuel Satie, Tim Engelhardt, Maga, Sean Doron, Unfazed, Scenarios – A Gira (Extended Remix) (5:28)
11. DJ Koze – Spiralen (Original Mix) (7:17)
12. Joris Voorn, Taylr Renee & Roddy Lima – Machine (Original Mix) (5:02)
13. KREAM & Korolova – Annihilation (Extended Mix) (4:50)
14. Bedouin, Karl Williams, LUCH – The Only One (Original Mix) (6:49)
15. Massano & Silver Panda – Pa Ca (Original Mix) (4:47)
16. Moeaike, Arodes – Nobody Else (Extended Mix) (5:58)
17. RÜFÜS DU SOL – In the Moment (Adriatique Extended Remix) (5:12)
18. &ME, Black Coffee – The Rapture Pt.III (Original Mix) (8:02)
19. Liva K – Didn’t Miss You (Original Mix) (5:46)
20. Carlo Whale & TH;EN – Echoes of You (Original Mix) (5:22)
21. Magnus, JEWELS, YUMA., SOMMA, LE YORA – EVERYTHING IN ITS RIGHT PLACE (Original Mix) (7:14)
22. Andrea Oliva – Neon Hearts (Original Mix) (6:06)
23. Jackie Hollander – High On You (Extended Mix) (4:57)
24. KREAM, SCRIPT – Turn Up The Dose (Extended Mix) (4:20)
25. JOSEPH (CH) – B4U (Original Mix) (8:00)
26. Ajna (BE), Misha – Body Moving (Original Mix) (6:16)
27. Green Velvet & Harvard Bass – Lazer Beams (Adam Beyer & Massano Remix) (5:02)
28. Argy, Omiki & Lell Nahar – MANTRA (Extended Mix) (5:03)
29. Sasha & Franky Wah – Get On My Level (Original Mix) (8:00)
30. LUCH – UH HUH (Original Mix) (6:45)
31. Fahlberg – Feeling Good (Extended Mix) (6:49)
32. Ivory (IT) – Back Again (Original Mix) (5:42)
33. Massano, Y Do I – Lost My Mind (Original Mix) (5:23)
34. KOWSKI, Eric Reyes – Break Down! (Original Mix) (5:30)
35. Andrea Oliva – Spotlight (Original Mix) (6:21)
36. Tiga vs Meduza – You Gonna Want Me (I Know) (Extended Mix) (5:21)
37. Marsh – Beside (Extended Mix) (6:18)
38. Moderat, &ME, Rampa & Keinemusik – More Love (Rampa &ME Remix) (4:55)
39. Adriatique & Emmit Fenn – Closer (Extended Mix) (4:53)
40. Jimi Jules, Black Coffee – Trippy Yeah (Extended Mix) (6:40)
41. Liva K & OVEOUS – Blessings (Original Mix) (6:35)
42. Township Rebellion, Kasablanca – It’s Chemical (Original Mix) (6:28)
43. MAXI MERAKI, Sharing-DB.club – First Time (Original Mix) (5:19)
44. Pole Position – Touch Me (Hotlap Remix) (5:40)
45. Da Hool, Cassian & YOTTO – Love Parade (Extended Mix) (4:09)
46. Jody Wisternoff, Jamie McDermott – Citadel (Extended Mix) (5:17)
47. Super Flu, Dennis Louvra – Can’t Escape (Original Mix) (5:56)
48. Armin van Buuren, Argy, Marlo Rex – Like A Child (Extended Mix) (4:08)
49. Robert Babicz – The Wave (Original Mix) (5:08)
50. The Temper Trap – Sweet Disposition (John Summit & Silver Panda Extended Remix) (5:44)
51. Yotto, Tallac – Chemtrail Surfers (Extended Mix) (6:26)
52. HotLap – Waitin’ (Extended Mix) (6:35)
53. Miss Monique, Nicolas Taboada & FRANCO BA – Ghosts (Extended Mix) (5:28)
54. Picca & Mars & Moeaike – Move in Time (Original Mix) (5:39)
55. Hardt Antoine – Sing It Back (Extended Mix) (6:57)
56. Shakedown, Layton Giordani & Anyma (ofc) – At Night (Anyma x Layton Giordani Extended Remix) (4:35)
57. Acrobatik & whatlewisdoes – Lamazi (Extended Mix) (4:46)
58. Massano – Abraza (Original Mix) (5:13)
59. Alcatraz – Giv Me Luv (Jerome Isma-Ae Remix) (7:18)
60. Gespona – Komplex (Original Mix) (5:56)
61. Misha & Martim Rola – On Business (Extended Mix) (6:30)
62. Mind Against, Anakim & HUMAN404 – Late at Night (Extended Mix) (4:39)
63. Anyma (ofc), Argy & Son of Son – Voices In My Head (Extended Mix) (4:16)
64. Fred again.., Swedish House Mafia, Future – Turn On The Lights again.. (feat. Future) (Original Mix) (4:27)
65. Sezer Uysal & Axelax – Calling For More (Original Mix) (5:12)
66. LUCH, Marten Lou – Hideaway (Extended Version) (5:57)
67. Giuseppe Ottaviani, ARTBAT & Conor Ross – Break The Loop (Original Mix) (5:22)
68. Rhodes, CamelPhat, ARTBAT – For a Feeling (Adam Sellouk & Y do I Remix – Extended) (4:49)
69. Monolink, Artbat – Return to Oz (ARTBAT Remix) (8:00)
70. Lisa & Anyma (ofc) – Bad Angel (with Lisa) (Extended Mix) (4:08)
71. Florist (BE) – Unite (Original Mix) (6:25)
72. Moderat – Eating Hooks (Deep Dish Extended Remix) (7:13)
73. CamelPhat & Arodes – Cycles (Extended Mix) (6:52)
74. Interplanetary Criminal, Kettama – Yosemite (Extended) (6:02)
75. Leblanc (ofc) – Strings Anthem (Original Mix) (5:22)
76. Mind Against & Jos & Eli – Overture (Original Mix) (5:21)
77. Tripolism & Kingstarlight – Hurricane (Extended Mix) (4:49)
78. Luke Alexander – Last Night (feat. Y4MI) (Original Mix) (3:30)
79. Braxton – There’s Still Time (Extended Mix) (6:31)
80. Township Rebellion – Birds Fly First Class (Original Mix) (6:19)
81. Agents Of Time & Miss Monique – Rajada (Original Mix) (5:00)
82. Fancy Inc – Hypnotic (Extended Mix) (5:09)
83. Boys Noize, &ME, Rampa, Adam Port, Keinemusik, Vinson – Crazy For It (feat. Vinson) (Original Mix) (6:01)
84. Space Motion & Luca Belluco – Baila Conmigo (Original Mix) (4:38)
85. Tom Westy & Dansyn – Feel Your Soul (Extended) (4:33)
86. CamelPhat & Josh Gigante – The One (Extended Mix) (5:32)
87. Argy & Omnya – Aria (Extended Mix) (5:14)
88. Air & Tur, blou., Airsand, TuraniQa – Teach Me (Extended Mix) (4:35)
89. Dilby, Martin Ka – Moving Trough (Extened Mix) (6:16)
90. Pablo Say – Repique (Original Mix) (4:48)
91. Orsay & Kotiēr – 1886 (Original Mix) (5:46)
92. Adriatique & Elderbrook – Lost In The Woods (Extended Mix) (5:01)
93. Ajna (BE) & Sentin – Bringing The Noise (Original Mix) (5:45)
94. Eric Prydz – Pjanoo (Club Mix) (7:30)
95. Eelke Kleijn – Feel It, See It (Extended Mix) (6:07)
96. Aera – Litoral (Original Mix) (6:10)
97. Julian Koerndl – Horses (Original Mix) (6:09)
98. Supermode – Tell Me Why (MEDUZA Extended Remix) (8:28)
99. Laherte – Pump Up The Jam (Extended Mix) (3:58)
100. M.O.S. – Changes (Extended Mix) (6:43)
-7
View File
@@ -1,7 +0,0 @@
https://open.spotify.com/playlist/0Q4JvzeT0q3FLsj5fqffMV?si=d17c4002f6f349c4
https://open.spotify.com/playlist/4FHKFYziOZF75AkT3aR7QP?si=b013d1cc79ff47a3
https://open.spotify.com/playlist/0mpX2EakDLmFjifuh1fQKU?si=bab58f6a7e984560
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
https://open.spotify.com/playlist/4MmZgwwHgrSs2astI4NVxI?si=021eef1a758a42fc
https://open.spotify.com/playlist/4OAx11L58sozuyNVTBi6r4?si=27d4be41e53c4e8a
-18
View File
@@ -1,18 +0,0 @@
https://open.spotify.com/playlist/5HVNSaqs3HgzIsaCVeVGrI?si=dd86ade90d2347ae
https://open.spotify.com/playlist/7ptoSkENfnO1yEQYrly725?si=4d694c3390a54ce6
https://open.spotify.com/playlist/6VjXyFH9Z5HlGPAjRBKR32?si=e8fc98ee593e4e69
https://open.spotify.com/playlist/4rdb0iJ7hZhqgBFpXHGVU6?si=96a6838af2a3487f
https://open.spotify.com/playlist/51ireDdqSrt6juqBxkMsrm?si=6c06fc88e7354b50
https://open.spotify.com/playlist/50SNkybvZnubt5uK4F2kgk?si=b7a146e4d8a54d35
https://open.spotify.com/playlist/0erLbMzXD0U9xJUPlOeKrX?si=54c87940c7be4c65
https://open.spotify.com/playlist/3FGGBSXjLGU5kfPCxL9K6f?si=77e1eee04a0745aa
https://open.spotify.com/playlist/2OxJrcgA6AQGk1sSQ8ZZ2a?si=98ede53357124322
https://open.spotify.com/playlist/345eEKyGNyluygzMsS1n1H?si=9d4be71b28e94339
https://open.spotify.com/playlist/0Umelkk6D75eZHZuxkTtLF?si=7c2f1ff3d2354abb
https://open.spotify.com/playlist/2k9QmVpMHJfyjVVvJdnRLb?si=765f62a3caf843ef
https://open.spotify.com/playlist/7xVYvU65ezCBiOu49BSV4I?si=608741585f91483d
https://open.spotify.com/playlist/1AOMaSEZf0aChEOK6L5uI3?si=e44863bf2782419b
https://open.spotify.com/playlist/3Zn4AOuMAKFCJD4VLUD0uY?si=2179f9cb5e5b4092
https://open.spotify.com/playlist/1LA5WDfX5Z4I0fQSIx4L8N?si=9918a8eadb5744b9
https://open.spotify.com/playlist/0NHDUXVEsoFMwzkrYaRHG0?si=58e61fe6319e48cb
https://open.spotify.com/playlist/2Y6PQB7k73HCrBFwiBy2Jf?si=8da4990576cd444f
+92 -32
View File
@@ -1,6 +1,6 @@
# MusicDownload # MusicTools
Applicazione desktop per scaricare **musica** da Spotify, YouTube e SoundCloud, e **video** da YouTube, TikTok, Instagram e Facebook. Include anche un upgrader di qualità per migliorare i file audio già scaricati. Applicazione desktop per **scaricare musica e video**, **modificare i metadati** dei file audio e **registrare l'audio** da qualsiasi ingresso del computer.
UI moderna basata su **pywebview** (WebKit su macOS, EdgeChromium su Windows) — frontend HTML/CSS/JS, backend Python. UI moderna basata su **pywebview** (WebKit su macOS, EdgeChromium su Windows) — frontend HTML/CSS/JS, backend Python.
@@ -8,14 +8,26 @@ Sviluppata da **LuZa**.
Repo: <https://github.com/luzadev/musicdownload> Repo: <https://github.com/luzadev/musicdownload>
> ℹ️ Il repo Git conserva il nome storico **`musicdownload`** (l'app prima si chiamava così); URL stabili per release già pubblicate e installazioni esistenti.
## Funzionalità ## Funzionalità
- **Scarica musica** — Incolla un link Spotify (playlist/album/brano), YouTube, SoundCloud o qualsiasi sorgente yt-dlp e scarica in MP3 a 320 kbps. Supporta liste di URL da file `.txt`. | Tab | Cosa fa |
- **Scarica video** — Incolla un link **YouTube · TikTok · Instagram · Facebook** (o qualsiasi sorgente yt-dlp) e scarica in MP4 alla qualità scelta (best / 1080p / 720p / 480p). |---|---|
- **Upgrade qualità** — Scansiona una cartella di file audio e riscarica quelli sotto la soglia kbps impostata. | **⬇ Scarica** | Spotify (playlist/album/brano) · YouTube · SoundCloud · lista URL `.txt` · tracklist `.txt` con titoli (es. Beatport Top 100) |
- **Auto-skip dei duplicati** — Tracking persistente (`.downloaded_tracks` / `.downloaded_videos`) + scan filename token-based: re-eseguire la stessa playlist salta i brani già presenti, anche se il filename ha "Official Video" / "Lyrics" / ecc. | **🎬 Video** | YouTube · TikTok · Instagram · Facebook (e 1000+ siti yt-dlp) in MP4, qualità best/1080p/720p/480p |
- **Aggiornamento automatico** — Verifica disponibilità nuove versioni dalla pagina Impostazioni. | **⚡ Upgrade** | Scansiona una cartella audio e riscarica i file sotto la soglia kbps impostata |
- **Lista multi-URL** — Carica un file `.txt` con un URL per riga per scaricare più playlist in sequenza. | **🏷 Metadati** | Editor tag ID3 / MP4 / Vorbis (MP3, M4A, FLAC, WAV) — titolo, artista, album, anno, copertina, BPM, key, commento, origine (macOS) |
| **● Registra** | Cattura audio in MP3 320 kbps da qualsiasi ingresso (mixer, scheda audio, microfono, BlackHole/loopback) |
| **⚙ Impostazioni** | Credenziali Spotify, bitrate, soglia HQ, percorsi, tema, controllo aggiornamenti |
### Auto-skip dei duplicati
Tracking persistente (`.downloaded_tracks` / `.downloaded_videos`) + scan filename token-based: re-eseguire la stessa playlist salta i brani già presenti, anche se il filename ha `Official Video` / `Lyrics` / ecc.
### Sottocartella automatica per le tracklist
Se carichi un file `.txt` con titoli (es. `Beatport Top 100 Melodic House & Techno.txt`), l'app crea automaticamente una sottocartella `<output_dir>/Beatport Top 100 Melodic House & Techno/` per non mischiare brani di playlist diverse.
## Requisiti ## Requisiti
@@ -39,8 +51,9 @@ Dipendenze principali:
| Pacchetto | Utilizzo | | Pacchetto | Utilizzo |
|---|---| |---|---|
| `pywebview` | Finestra nativa con WebView (WebKit / EdgeChromium) | | `pywebview` | Finestra nativa con WebView (WebKit / EdgeChromium) |
| `requests` | Chiamate API Spotify | | `requests` | Chiamate API Spotify e GitHub |
| `yt-dlp` | Download audio/video da YouTube e oltre 1000 siti | | `yt-dlp` | Download audio/video da YouTube e oltre 1000 siti |
| `mutagen` | Lettura/scrittura tag ID3, MP4, FLAC, WAV |
| `pyobjc-framework-WebKit` (macOS) | Backend WebKit di pywebview | | `pyobjc-framework-WebKit` (macOS) | Backend WebKit di pywebview |
| `pythonnet` (Windows) | Backend EdgeChromium di pywebview | | `pythonnet` (Windows) | Backend EdgeChromium di pywebview |
@@ -60,10 +73,63 @@ Le credenziali sono gratuite, non servono Spotify Premium.
Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTube serve un file `cookies.txt` (formato Netscape). Esportalo dal browser con un'estensione tipo "Get cookies.txt LOCALLY" e imposta il percorso in **Impostazioni → Percorsi**. Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTube serve un file `cookies.txt` (formato Netscape). Esportalo dal browser con un'estensione tipo "Get cookies.txt LOCALLY" e imposta il percorso in **Impostazioni → Percorsi**.
## Guida rapida: registrazione audio di sistema (macOS)
macOS non espone nativamente l'output audio del sistema. Per registrare quello che esce dagli speaker (es. uno stream Spotify/YouTube), serve un driver virtuale di **loopback**. Il più diffuso è **BlackHole**, gratuito e open source.
### 1. Installazione
```bash
brew install blackhole-2ch
```
> Subito dopo l'installazione, se non vedi BlackHole nella tab Registra, riavvia il servizio audio:
> ```bash
> sudo killall coreaudiod
> ```
> Si rilancia da solo in pochi secondi, nessun danno. In alternativa, logout/login o riavvio del Mac.
### 2. Permesso Microfono
Anche se non è un microfono fisico, macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation.
Vai su **Preferenze di Sistema → Privacy e sicurezza → Microfono** e abilita l'app da cui lanci MusicTools (Terminal/iTerm in sviluppo, oppure MusicTools.app per la build distribuita). Se non vedi nessuna voce, comparirà un popup la prima volta che premi REC — accetta.
### 3. Setup A — minimo (registri ma non senti l'audio durante)
1. Click sull'icona altoparlante nella barra menu → **Output → BlackHole 2ch**
*Oppure: Preferenze di Sistema → Audio → Uscita → BlackHole 2ch*
2. Fai partire lo stream (Spotify/YouTube): non sentirai nulla — è normale, l'audio va a BlackHole
3. App MusicTools → tab **● Registra** → dispositivo **🔄 BlackHole 2ch (loopback)** → **REC**
4. **Stop** e riascolta il file MP3 generato
### 4. Setup B — completo (registri E senti l'audio contemporaneamente, consigliato)
1. Apri **Configurazione MIDI Audio** (`Applicazioni → Utility → Configurazione MIDI Audio`)
2. In basso a sinistra: **+ → Crea dispositivo aggregato a uscita multipla** (Multi-Output Device)
3. Nella colonna a destra, spunta sia **BlackHole 2ch** sia i tuoi **Altoparlanti integrati** (o le cuffie)
4. Imposta **BlackHole 2ch** come **Master Device** (menù a tendina in alto)
5. Abilita **Drift Correction** sulla riga degli altoparlanti
6. (Opzionale) Rinomina il dispositivo aggregato in `Speakers + BlackHole`
7. **Preferenze di Sistema → Audio → Uscita** → seleziona `Speakers + BlackHole`
8. Nella tab Registra dell'app scegli **🔄 BlackHole 2ch (loopback)** — **NON il dispositivo aggregato**, quello è solo per riprodurre
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
### Troubleshooting
| Sintomo | Causa probabile | Fix |
|---|---|---|
| BlackHole non compare nel dropdown | CoreAudio non l'ha ancora caricato dopo l'install | `sudo killall coreaudiod` poi premi **↻ Aggiorna** |
| Errore `ffmpeg exit -6` o `permesso Microfono mancante` | macOS nega l'accesso al device | Privacy e sicurezza → Microfono → abilita Terminal/MusicTools |
| File MP3 muto | Output di sistema non passa per BlackHole | Cambia output su BlackHole o Multi-Output Device (vedi Setup A/B) |
| Audio doppio/eco nel file | Stai registrando da BlackHole *e* da microfono fisico simultaneamente | Verifica che nel dropdown ci sia solo `BlackHole 2ch` |
| Audio crackly/distorto | Sample rate non allineato | In Audio MIDI Setup, BlackHole + altoparlanti tutti a 48 kHz |
## Struttura progetto ## Struttura progetto
``` ```
MusicDownload/ MusicTools/
├── main.py # Entry point — bootstrap pywebview ├── main.py # Entry point — bootstrap pywebview
├── requirements.txt ├── requirements.txt
├── build_macos.py # Script build macOS (.app) ├── build_macos.py # Script build macOS (.app)
@@ -75,14 +141,16 @@ MusicDownload/
│ ├── config.py # Configurazione persistente (JSON) │ ├── config.py # Configurazione persistente (JSON)
│ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg) │ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg)
│ ├── spotify_client.py # API Spotify (Client Credentials) │ ├── spotify_client.py # API Spotify (Client Credentials)
│ ├── downloader.py # Download audio (playlist Spotify, URL diretti, video) │ ├── downloader.py # Download audio + video (Spotify, yt-dlp diretto)
│ └── upgrader.py # Upgrade qualità file esistenti │ ├── upgrader.py # Upgrade qualità file esistenti
│ ├── metadata.py # Lettura/scrittura tag ID3/MP4/Vorbis/WAV
│ └── recorder.py # Registrazione audio via ffmpeg
│ │
├── api/ ├── api/
│ └── bridge.py # Ponte JS <-> Python (pywebview.js_api) │ └── bridge.py # Ponte JS <-> Python (pywebview.js_api)
│ │
└── webui/ # Frontend └── webui/ # Frontend
├── index.html # Single-page, 4 view (Scarica / Video / Upgrade / Impostazioni) ├── index.html # Single-page, 6 view
├── css/style.css # Tema scuro Spotify-like, gradient hero, animazioni ├── css/style.css # Tema scuro Spotify-like, gradient hero, animazioni
└── js/app.js # Routing, eventi, chiamate API └── js/app.js # Routing, eventi, chiamate API
``` ```
@@ -103,8 +171,8 @@ Il file `config.json` viene creato automaticamente al primo avvio:
Posizione: Posizione:
- **Sviluppo**: directory del progetto - **Sviluppo**: directory del progetto
- **macOS bundle**: `~/Library/Application Support/MusicDownload/` - **macOS bundle**: `~/Library/Application Support/MusicTools/`
- **Windows bundle**: `%APPDATA%/MusicDownload/` - **Windows bundle**: `%APPDATA%/MusicTools/`
## Build locale ## Build locale
@@ -116,7 +184,7 @@ pip install pyinstaller
python3 build_macos.py python3 build_macos.py
``` ```
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`). Produce `dist/MusicTools.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
### Windows ### Windows
@@ -125,44 +193,36 @@ pip install pyinstaller
python build_windows.py python build_windows.py
``` ```
Produce `dist/MusicDownload/MusicDownload.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build. Produce `dist/MusicTools/MusicTools.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build.
## Build & Release automatiche (GitHub Actions) ## Build & Release automatiche (GitHub Actions)
Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`: Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`:
1. **`build-macos`** (runner `macos-latest`) → `MusicDownload-macOS.zip` (la `.app` zippata con `ditto`) 1. **`build-macos`** (runner `macos-latest`) → `MusicTools-macOS.zip` (la `.app` zippata con `ditto`)
2. **`build-windows`** (runner `windows-latest`) → `MusicDownload-Windows.zip` 2. **`build-windows`** (runner `windows-latest`) → `MusicTools-Windows.zip`
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati 3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
Per pubblicare una nuova versione: Per pubblicare una nuova versione:
```bash ```bash
git tag v1.2.0 git tag v1.3.0
git push origin v1.2.0 git push origin v1.3.0
``` ```
Per fare solo una build di test (senza release): Per fare solo una build di test (senza release):
```bash ```bash
gh workflow run "Build MusicDownload" --ref main gh workflow run "Build MusicTools" --ref main
``` ```
oppure dalla UI: **Actions → Build MusicDownload → Run workflow**. oppure dalla UI: **Actions → Build MusicTools → Run workflow**.
Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>. Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>.
## Aggiornamento app ## Aggiornamento app
L'app verifica nuove versioni interrogando un `version.json` remoto. URL configurato in `api/bridge.py` (`UPDATE_URL` nel metodo `check_update`): L'app verifica nuove versioni interrogando l'API GitHub Releases (`api/bridge.py` → `check_update`). Per funzionare end-to-end serve che il repo sia pubblico, altrimenti l'API restituisce 404.
```json
{
"version": "v1.2.0",
"download_url": "https://github.com/luzadev/musicdownload/releases/latest",
"notes": "Descrizione delle novità"
}
```
## Licenza ## Licenza
-5
View File
@@ -1,5 +0,0 @@
https://open.spotify.com/intl-it/album/3lgCqbQpmLscSRuWVMT5wS?si=UPiqK8smRgO_1JAAiZTrYg
https://open.spotify.com/intl-it/album/3pGb3m5KA1Y3gCVT6leCL3?si=Cj_OrTE5QAiEnwMD63EunA
https://open.spotify.com/intl-it/album/2ewhdpODXqoB0fXIKfq4u2?si=e2N7jWHiTWGiudHHMJNHHQ
https://open.spotify.com/intl-it/album/2xGfPfmx6faZUr9IaKEiSG?si=2Q-qMwWCTp-Yp7RIfhE2Ig
https://open.spotify.com/intl-it/album/43pKffJweYUVwEYcHPIfox?si=2E8IoUmOQp-cgsCujVBPMQ
+320 -29
View File
@@ -10,7 +10,8 @@ import time
import webbrowser import webbrowser
from typing import Any, Optional from typing import Any, Optional
from core.config import load_config, save_config, VERSION from core.config import load_config, save_config, VERSION, LICENSE_API_URL
from core import license as license_mod
from core.downloader import ( from core.downloader import (
download_playlist, download_playlist,
download_direct_url, download_direct_url,
@@ -19,7 +20,20 @@ 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.recorder import (
list_input_devices,
start_recording,
stop_recording as recorder_stop,
is_recording,
get_last_stderr as recorder_last_stderr,
)
from core.upgrader import ( from core.upgrader import (
upgrade_folder, upgrade_folder,
request_stop as request_upgrade_stop, request_stop as request_upgrade_stop,
@@ -28,10 +42,18 @@ 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: MusicDownload (o quello che vuoi) - App name: MusicTools (o quello che vuoi)
- Description: a piacere - Description: a piacere
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato) - Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
- Seleziona "Web API" - Seleziona "Web API"
@@ -41,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.
""" """
@@ -106,10 +128,16 @@ class Api:
def get_init_data(self) -> dict: def get_init_data(self) -> dict:
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale.""" """Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
cfg = load_config() cfg = load_config()
# Non spediamo il token raw al frontend (e' sensibile e non serve in UI)
safe_cfg = {k: v for k, v in cfg.items() if k != "license_token"}
license_status = license_mod.get_status(cfg)
return { return {
"version": VERSION, "version": VERSION,
"config": cfg, "config": safe_cfg,
"spotify_guide": SPOTIFY_GUIDE_TEXT, "spotify_guide": SPOTIFY_GUIDE_TEXT,
"license": license_status,
"plan": license_mod.get_plan(cfg),
"purchase_url": "https://musictools.djluza.com",
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -121,7 +149,10 @@ class Api:
except (TypeError, ValueError): except (TypeError, ValueError):
threshold = 310 threshold = 310
config = { # Merge sul config esistente per preservare i campi licenza
# (license_key/token/email/...), device_id, ecc.
config = load_config()
config.update({
"client_id": (payload.get("client_id") or "").strip(), "client_id": (payload.get("client_id") or "").strip(),
"client_secret": (payload.get("client_secret") or "").strip(), "client_secret": (payload.get("client_secret") or "").strip(),
"bitrate": payload.get("bitrate", "320K"), "bitrate": payload.get("bitrate", "320K"),
@@ -129,28 +160,149 @@ class Api:
"cookies_path": (payload.get("cookies_path") or "").strip(), "cookies_path": (payload.get("cookies_path") or "").strip(),
"output_dir": (payload.get("output_dir") or "").strip(), "output_dir": (payload.get("output_dir") or "").strip(),
"theme": payload.get("theme", "dark"), "theme": payload.get("theme", "dark"),
} })
save_config(config) save_config(config)
return {"ok": True} return {"ok": True}
# ------------------------------------------------------------------
# Licenza
# ------------------------------------------------------------------
def get_license_status(self) -> dict:
return license_mod.get_status()
def activate_license(self, payload: dict) -> dict:
try:
status = license_mod.activate(
payload.get("key") or "",
payload.get("email") or "",
)
return {"ok": True, "license": status}
except license_mod.LicenseNetworkError as e:
return {"ok": False, "error": f"Impossibile contattare il server: {e}"}
except license_mod.LicenseError as e:
return {"ok": False, "error": str(e)}
def deactivate_license(self) -> dict:
status = license_mod.deactivate(release_remote=True)
return {"ok": True, "license": status}
def revalidate_license(self) -> dict:
"""Revalidate in foreground (chiamabile dalla UI 'Verifica ora')."""
status = license_mod.validate()
return {"ok": True, "license": status}
def open_purchase_page(self) -> dict:
webbrowser.open("https://musictools.djluza.com")
return {"ok": True}
# ------------------------------------------------------------------
# Quota giornaliera
# ------------------------------------------------------------------
def get_quota_status(self) -> dict:
"""Ritorna lo stato quota corrente (lettura senza incremento)."""
try:
data = license_mod.get_quota_status()
return {"ok": True, "quota": data}
except license_mod.LicenseNetworkError as e:
return {"ok": False, "offline": True, "error": str(e)}
except license_mod.LicenseError as e:
return {"ok": False, "error": str(e)}
def _gate(self, feature: str) -> Optional[dict]:
"""Verifica feature + quota prima di un'azione.
Ritorna None se l'azione e' permessa; altrimenti un dict di
errore pronto per la UI (con eventuale info quota per il modal
"limite raggiunto").
"""
# 1) Feature gate locale (dai claims JWT in config)
if not license_mod.has_feature(feature):
plan = license_mod.get_plan()
plan_name = plan.get("name") or "il tuo piano"
return {
"ok": False,
"reason": "feature_not_in_plan",
"feature": feature,
"error": (
f"Questa funzione non e' inclusa in {plan_name}. "
"Passa a un piano superiore per sbloccarla."
),
"plan": plan,
}
# 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:
res = license_mod.consume_quota(feature)
except license_mod.LicenseNetworkError as e:
return {
"ok": False, "reason": "offline", "offline": True,
"error": f"Impossibile contattare il server: {e}",
}
except license_mod.LicenseError as e:
return {"ok": False, "reason": "license_invalid", "error": str(e)}
if not res.get("allowed", True):
return {
"ok": False,
"reason": "quota_exceeded",
"feature": feature,
"error": res.get("error") or "Limite giornaliero raggiunto.",
"quota": res,
}
# OK: notifico la UI dello stato quota aggiornato
self._emit("quota:update", res)
return None
def check_update(self) -> dict: def check_update(self) -> dict:
"""Controlla aggiornamenti chiamando version.json remoto.""" """Controlla aggiornamenti interrogando l'API djluza.com.
Il server identifica la piattaforma e restituisce un URL di
download firmato a tempo. Senza un token di licenza valido
ritorna comunque la versione corrente ma con download_url vuoto.
"""
import platform
import urllib.error
import urllib.request import urllib.request
UPDATE_URL = "https://www.djluza.com/musicdownload/version.json" cfg = load_config()
token = (cfg.get("license_token") or "").strip()
is_macos = platform.system() == "Darwin"
plat = "macos" if is_macos else "windows"
url = f"{LICENSE_API_URL.rstrip('/')}/api/latest?platform={plat}&current={VERSION}"
headers = {
"User-Agent": f"MusicTools/{VERSION}",
"Accept": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers, method="GET")
try: try:
req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "MusicDownload"})
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode()) data = json.loads(resp.read().decode("utf-8"))
remote = data.get("version", "") remote = data.get("version", "")
notes = data.get("notes", "") or ""
download_url = data.get("download_url", "") or ""
return { return {
"ok": True, "ok": True,
"current": VERSION, "current": VERSION,
"remote": remote, "remote": remote,
"is_new": bool(remote and remote != VERSION), "is_new": bool(remote and remote != VERSION),
"download_url": data.get("download_url", ""), "download_url": download_url,
"notes": data.get("notes", ""), "notes": notes[:500] if notes else "",
"requires_license": not bool(download_url) and not token,
} }
except urllib.error.HTTPError as e:
return {"ok": False, "error": f"HTTP {e.code}: {e.reason}", "current": VERSION}
except (urllib.error.URLError, TimeoutError, OSError) as e:
return {"ok": False, "error": f"Connessione fallita: {e}", "current": VERSION}
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e), "current": VERSION} return {"ok": False, "error": str(e), "current": VERSION}
@@ -210,13 +362,110 @@ class Api:
t = _parse_track_line(line) t = _parse_track_line(line)
if t: if t:
tracks.append(t) tracks.append(t)
return {"ok": True, "kind": "tracks", "tracks": tracks, "count": len(tracks)} name = os.path.splitext(os.path.basename(path))[0]
return {
"ok": True, "kind": "tracks", "tracks": tracks,
"count": len(tracks), "name": name,
}
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
def open_external_url(self, url: str) -> None: def open_external_url(self, url: str) -> None:
webbrowser.open(url) webbrowser.open(url)
# ------------------------------------------------------------------
# Metadata editor
# ------------------------------------------------------------------
def pick_audio_file(self) -> str:
return self.browse_file([
"Audio (*.mp3;*.m4a;*.mp4;*.aac;*.flac;*.wav)",
"All files (*.*)",
])
def pick_image_file(self) -> str:
return self.browse_file([
"Immagini (*.jpg;*.jpeg;*.png)",
"All files (*.*)",
])
def read_metadata(self, path: str) -> dict:
try:
data = read_metadata(path)
return {"ok": True, "data": data}
except Exception as e:
return {"ok": False, "error": str(e)}
def save_metadata(self, payload: dict) -> dict:
path = (payload.get("path") or "").strip()
if not path:
return {"ok": False, "error": "Path mancante"}
if not os.path.exists(path):
return {"ok": False, "error": "File non trovato"}
gate = self._gate("metadata")
if gate:
return gate
data = payload.get("data") or {}
cover_path = (payload.get("cover_path") or "").strip() or None
remove_cover = bool(payload.get("remove_cover", False))
try:
write_metadata(path, data, cover_path, remove_cover)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
# ------------------------------------------------------------------
# Recorder
# ------------------------------------------------------------------
def list_audio_inputs(self) -> dict:
try:
return {"ok": True, "devices": list_input_devices()}
except Exception as e:
return {"ok": False, "error": str(e), "devices": []}
def start_audio_recording(self, payload: dict) -> dict:
device_id = str(payload.get("device_id", "")).strip()
output_dir = (payload.get("output_dir") or "").strip()
filename = (payload.get("filename") or "").strip()
bitrate = (payload.get("bitrate") or "320k").strip()
if not device_id:
return {"ok": False, "error": "Dispositivo non selezionato"}
if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"}
if not filename:
filename = f"Registrazione_{time.strftime('%Y%m%d_%H%M%S')}.mp3"
if not filename.lower().endswith(".mp3"):
filename += ".mp3"
gate = self._gate("record")
if gate:
return gate
out_path = os.path.join(output_dir, filename)
def cb(status, payload_evt):
payload_evt = dict(payload_evt or {})
payload_evt["status"] = status
self._emit("recording:event", payload_evt)
res = start_recording(device_id, out_path, bitrate=bitrate,
progress_callback=cb)
return res
def stop_audio_recording(self) -> dict:
return recorder_stop()
def is_audio_recording(self) -> dict:
return {"recording": is_recording()}
def get_recorder_log(self) -> dict:
"""Ritorna le ultime righe di stderr di ffmpeg dell'ultima sessione
di registrazione. Utile per diagnosticare un errore."""
return {"ok": True, "lines": recorder_last_stderr(80)}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Audio folder scan (Upgrade tab) # Audio folder scan (Upgrade tab)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -247,6 +496,10 @@ class Api:
if not output_dir: if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"} return {"ok": False, "error": "Cartella output non impostata"}
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread( self._download_thread = threading.Thread(
target=self._download_worker, target=self._download_worker,
args=(list(urls), output_dir), args=(list(urls), output_dir),
@@ -257,17 +510,29 @@ class Api:
def start_tracks_download(self, payload: dict) -> dict: def start_tracks_download(self, payload: dict) -> dict:
"""Avvia il download di una tracklist gia parsata """Avvia il download di una tracklist gia parsata
(lista di {name, artist}).""" (lista di {name, artist}). Se 'subfolder' e presente,
scarica in output_dir/subfolder."""
if self._any_job_running(): if self._any_job_running():
return {"ok": False, "error": "Un download gia in corso"} return {"ok": False, "error": "Un download gia in corso"}
tracks = payload.get("tracks") or [] tracks = payload.get("tracks") or []
output_dir = (payload.get("output_dir") or "").strip() output_dir = (payload.get("output_dir") or "").strip()
subfolder = (payload.get("subfolder") or "").strip()
if not tracks: if not tracks:
return {"ok": False, "error": "Nessuna traccia fornita"} return {"ok": False, "error": "Nessuna traccia fornita"}
if not output_dir: if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"} return {"ok": False, "error": "Cartella output non impostata"}
if subfolder:
# Sanitizza: rimuovi separatori di path
safe = subfolder.replace("/", "_").replace("\\", "_").strip()
if safe:
output_dir = os.path.join(output_dir, safe)
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread( self._download_thread = threading.Thread(
target=self._tracks_worker, target=self._tracks_worker,
args=(list(tracks), output_dir), args=(list(tracks), output_dir),
@@ -289,6 +554,7 @@ class Api:
view = "download" view = "download"
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube") self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
self._log(view, f"[INFO] Destinazione: {output_dir}")
_last = [0.0] _last = [0.0]
_THROTTLE = 0.10 _THROTTLE = 0.10
@@ -342,14 +608,11 @@ class Api:
view = "download" view = "download"
token = None token = None
client_id = cfg.get("client_id", "")
client_secret = cfg.get("client_secret", "")
has_credentials = bool(client_id and client_secret)
has_spotify = any("spotify.com" in u for u in urls) has_spotify = any("spotify.com" in u for u in urls)
if has_spotify: if has_spotify and has_credentials:
client_id = cfg.get("client_id", "")
client_secret = cfg.get("client_secret", "")
if not client_id or not client_secret:
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
self._emit("download:done", {"ok": False})
return
self._log(view, "[INFO] Autenticazione Spotify...") self._log(view, "[INFO] Autenticazione Spotify...")
try: try:
token = get_access_token(client_id, client_secret) token = get_access_token(client_id, client_secret)
@@ -357,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():
@@ -416,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:
label, name, tracks = resolve_spotify_url(token, url) if token:
label, name, tracks = resolve_spotify_url(token, url)
else:
# Senza API keys: fallback solo per singoli brani.
label, name, tracks = resolve_spotify_track_no_auth(url)
except SpotifyAuthRequired as e:
self._log(view, f"[ERRORE] {e}")
continue
except Exception as e: except Exception as e:
self._log(view, f"[ERRORE] {e}") self._log(view, f"[ERRORE] {e}")
continue continue
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)") self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
dest_dir = os.path.join(output_dir, name) dest_dir = os.path.join(output_dir, name) if len(tracks) > 1 else output_dir
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb) download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
else: else:
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}") self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
@@ -453,6 +730,10 @@ class Api:
if not directory: if not directory:
return {"ok": False, "error": "Cartella non impostata"} return {"ok": False, "error": "Cartella non impostata"}
gate = self._gate("upgrade")
if gate:
return gate
cfg = load_config() cfg = load_config()
cookies_path = cfg.get("cookies_path", "") cookies_path = cfg.get("cookies_path", "")
@@ -519,15 +800,21 @@ class Api:
urls = payload.get("urls") or [] urls = payload.get("urls") or []
output_dir = (payload.get("output_dir") or "").strip() output_dir = (payload.get("output_dir") or "").strip()
quality = payload.get("quality") or "1080p" quality = payload.get("quality") or "1080p"
audio_only = bool(payload.get("audio_only", False))
if not urls: if not urls:
return {"ok": False, "error": "Nessun URL fornito"} return {"ok": False, "error": "Nessun URL fornito"}
if not output_dir: if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"} return {"ok": False, "error": "Cartella output non impostata"}
# Anche "audio_only" video count come video: usa codec/yt-dlp diversi
gate = self._gate("video")
if gate:
return gate
self._video_thread = threading.Thread( self._video_thread = threading.Thread(
target=self._video_worker, target=self._video_worker,
args=(list(urls), output_dir, quality), args=(list(urls), output_dir, quality, audio_only),
daemon=True, daemon=True,
) )
self._video_thread.start() self._video_thread.start()
@@ -538,10 +825,12 @@ class Api:
self._log("video", "[INFO] Interruzione richiesta...") self._log("video", "[INFO] Interruzione richiesta...")
return {"ok": True} return {"ok": True}
def _video_worker(self, urls: list, output_dir: str, quality: str) -> None: def _video_worker(self, urls: list, output_dir: str, quality: str,
audio_only: bool = False) -> None:
reset_download_stop() reset_download_stop()
cfg = load_config() cfg = load_config()
cookies_path = cfg.get("cookies_path", "") cookies_path = cfg.get("cookies_path", "")
bitrate = cfg.get("bitrate", "320K")
total_urls = len(urls) total_urls = len(urls)
multi = total_urls > 1 multi = total_urls > 1
view = "video" view = "video"
@@ -597,9 +886,11 @@ class Api:
self._emit("video:progress", payload_evt) self._emit("video:progress", payload_evt)
self._log(view, f"[INFO] Video: {url}") mode_lbl = f"Solo audio MP3 {bitrate}" if audio_only else f"Video {quality}"
self._log(view, f"[INFO] {mode_lbl}: {url}")
try: try:
download_video(url, output_dir, quality, cookies_path, progress_cb) download_video(url, output_dir, quality, cookies_path, progress_cb,
audio_only=audio_only, audio_bitrate=bitrate)
except Exception as e: except Exception as e:
self._log(view, f"[ERRORE] {e}") self._log(view, f"[ERRORE] {e}")
continue continue
+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."
+234
View File
@@ -0,0 +1,234 @@
"""Genera l'icona dell'app (PNG/ICNS/ICO) in modo riproducibile.
Stile: squircle (Apple-style rounded rect) con gradient verde Spotify
verso nero, nota musicale bianca al centro con leggero glow, highlight
sottile in alto.
Uso:
python3 assets/generate_icon.py
Produce:
assets/icon.png (1024x1024, master)
assets/icon.icns (macOS, generato via iconutil)
assets/icon.ico (Windows, multi-size)
assets/icon.iconset/... (intermedio macOS)
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Tuple
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
SIZE = 1024
RADIUS_RATIO = 0.225 # ~22% del lato, Apple iOS-like squircle
PADDING_RATIO = 0.085 # margine fra bordo immagine e squircle
# Palette (coerente col tema della UI)
GREEN_TOP = (30, 215, 96) # verde Spotify lucido
GREEN_BOTTOM = (15, 59, 34) # verde scuro profondo
WHITE = (255, 255, 255, 255)
HIGHLIGHT = (255, 255, 255, 38) # bianco semitrasparente per il riflesso
def vertical_gradient(size: int, top: Tuple[int, int, int],
bottom: Tuple[int, int, int]) -> Image.Image:
"""Crea un gradient verticale top -> bottom."""
img = Image.new("RGB", (size, size), top)
px = img.load()
for y in range(size):
t = y / (size - 1)
# ease (quad) per ammorbidire la transizione
t = t * t
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(size):
px[x, y] = (r, g, b)
return img
def squircle_mask(size: int, radius: int, padding: int) -> Image.Image:
"""Maschera alpha per un rounded rect centrato con padding."""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle(
(padding, padding, size - padding, size - padding),
radius=radius, fill=255,
)
return mask
def find_music_font() -> str:
"""Trova un font che contenga il glifo della nota musicale (♫)."""
candidates = [
"/System/Library/Fonts/Apple Symbols.ttf",
"/System/Library/Fonts/Symbol.ttf",
"/System/Library/Fonts/SFNS.ttf",
"/Library/Fonts/Arial Unicode.ttf",
]
for f in candidates:
if Path(f).exists():
return f
raise RuntimeError("Nessun font Unicode con simboli trovato")
def draw_note(img: Image.Image, glyph: str = "♫") -> None:
"""Stampa la nota musicale centrata, in bianco con leggero glow."""
font_path = find_music_font()
target_pt = int(SIZE * 0.62)
font = ImageFont.truetype(font_path, target_pt)
cx, cy = SIZE // 2, SIZE // 2 - int(SIZE * 0.01)
# Glow: stessa nota disegnata sfocata sotto in bianco semitrasparente
glow_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
glow_draw = ImageDraw.Draw(glow_layer)
glow_draw.text((cx, cy), glyph, font=font, fill=(255, 255, 255, 110),
anchor="mm")
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=24))
img.alpha_composite(glow_layer)
# Testo nitido sopra
draw = ImageDraw.Draw(img)
draw.text((cx, cy), glyph, font=font, fill=WHITE, anchor="mm")
def make_master() -> Image.Image:
"""Genera l'immagine master 1024x1024 RGBA con tutto."""
padding = int(SIZE * PADDING_RATIO)
radius = int(SIZE * RADIUS_RATIO)
# Background gradient verde
gradient = vertical_gradient(SIZE, GREEN_TOP, GREEN_BOTTOM)
# Squircle: prendiamo il gradient mascherato come forma
mask = squircle_mask(SIZE, radius, padding)
base = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
base.paste(gradient, (0, 0), mask)
# Highlight in alto: banda sottile (tipo "shine" iOS), molto trasparente
hl_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
hl_draw = ImageDraw.Draw(hl_layer)
hl_top = padding + int(SIZE * 0.04)
hl_bot = padding + int(SIZE * 0.16)
hl_left = padding + int(SIZE * 0.10)
hl_right = SIZE - padding - int(SIZE * 0.10)
hl_draw.ellipse(
(hl_left, hl_top, hl_right, hl_bot),
fill=(255, 255, 255, 30),
)
hl_layer = hl_layer.filter(ImageFilter.GaussianBlur(radius=18))
masked = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
masked.paste(hl_layer, (0, 0), mask)
base.alpha_composite(masked)
# Bordo interno sottile per dare definizione (stroke da 0.5%)
ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
ring_draw = ImageDraw.Draw(ring)
ring_draw.rounded_rectangle(
(padding, padding, SIZE - padding, SIZE - padding),
radius=radius,
outline=(255, 255, 255, 28),
width=max(2, int(SIZE * 0.004)),
)
base.alpha_composite(ring)
# Nota musicale al centro
draw_note(base)
# Lieve ombra interna in basso per profondita (vignette)
shade = Image.new("L", (SIZE, SIZE), 0)
sd = ImageDraw.Draw(shade)
sd.rounded_rectangle(
(padding, padding, SIZE - padding, SIZE - padding),
radius=radius, outline=255, width=int(SIZE * 0.025),
)
shade = shade.filter(ImageFilter.GaussianBlur(radius=int(SIZE * 0.025)))
overlay = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
overlay.paste((0, 0, 0, 60), mask=shade)
overlay = Image.new("RGBA", overlay.size, (0, 0, 0, 0)).convert("RGBA")
# Skip vignette complicata — base e' gia' coerente
return base
def export_iconset(master: Image.Image, outdir: Path) -> None:
"""Esporta le dimensioni standard macOS in outdir."""
outdir.mkdir(parents=True, exist_ok=True)
sizes = [
(16, "icon_16x16.png"),
(32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"),
(64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"),
(256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"),
(512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"),
(1024, "icon_512x512@2x.png"),
]
for px, name in sizes:
img = master.resize((px, px), Image.LANCZOS)
img.save(outdir / name, "PNG")
def build_icns(iconset_dir: Path, output_icns: Path) -> bool:
"""Usa iconutil di macOS per generare il .icns. Ritorna True se ok."""
if not shutil.which("iconutil"):
return False
try:
subprocess.run(
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(output_icns)],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def build_ico(master: Image.Image, output_ico: Path) -> None:
"""Genera il .ico multi-size per Windows."""
sizes = [(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)]
master.save(output_ico, format="ICO",
sizes=sizes, append_images=[])
def main():
print(f">>> Genero icona master {SIZE}x{SIZE}...")
master = make_master()
master_path = HERE / "icon.png"
master.save(master_path, "PNG")
print(f" {master_path}")
iconset_dir = HERE / "icon.iconset"
if iconset_dir.exists():
shutil.rmtree(iconset_dir)
print(">>> Esporto iconset macOS...")
export_iconset(master, iconset_dir)
icns_path = HERE / "icon.icns"
if build_icns(iconset_dir, icns_path):
print(f" {icns_path}")
else:
print(" SKIP icns (iconutil non disponibile)")
print(">>> Genero icon.ico per Windows...")
ico_path = HERE / "icon.ico"
build_ico(master, ico_path)
print(f" {ico_path}")
print("\nDone.")
if __name__ == "__main__":
main()
+146
View File
@@ -0,0 +1,146 @@
"""Genera i loghi brand MusicTools partendo dall'icona master.
Produce:
- logo_mark.png 1024x1024 solo squircle + nota, bg trasparente
- logo_horizontal_dark.png 2400x720 mark + "MusicTools" testo bianco (per bg scuri)
- logo_horizontal_light.png 2400x720 mark + "MusicTools" testo nero (per bg chiari)
- logo_stacked.png 1280x1600 mark sopra + testo sotto, bianco (per bg scuri)
Lo squircle riuso quello generato da generate_icon.py (assets/icon.png),
cosi' resta coerente con l'icona dell'app.
"""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HERE = Path(__file__).resolve().parent
ICON_PATH = HERE / "icon.png"
ARIAL_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
ARIAL_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
WHITE = (255, 255, 255)
BLACK = (10, 10, 10)
def font(size: int, *, black: bool = True) -> ImageFont.FreeTypeFont:
path = ARIAL_BLACK if black else ARIAL_BOLD
try:
return ImageFont.truetype(path, size)
except OSError:
return ImageFont.load_default()
def load_mark(target_size: int) -> Image.Image:
"""Carica l'icona master e la riscala alla dimensione richiesta.
L'icona originale ha sfondo trasparente fuori dallo squircle
(vedi generate_icon.py), quindi va bene per overlay su qualunque bg.
"""
if not ICON_PATH.exists():
raise FileNotFoundError(
f"icon.png non trovata: rilancia prima 'python3 assets/generate_icon.py'"
)
icon = Image.open(ICON_PATH).convert("RGBA")
if icon.size != (target_size, target_size):
icon = icon.resize((target_size, target_size), Image.LANCZOS)
return icon
def measure_text(text: str, f: ImageFont.FreeTypeFont) -> tuple[int, int]:
"""Ritorna (larghezza, altezza) effettiva del testo."""
bbox = f.getbbox(text)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def draw_text_top_left(canvas: Image.Image, text: str, top_left: tuple[int, int],
f: ImageFont.FreeTypeFont, color: tuple) -> None:
"""Disegna testo con angolo top-left = top_left (compensa font bbox)."""
draw = ImageDraw.Draw(canvas, "RGBA")
bbox = f.getbbox(text)
x = top_left[0] - bbox[0]
y = top_left[1] - bbox[1]
draw.text((x, y), text, font=f, fill=(*color, 255))
def make_mark(path: Path) -> None:
mark = load_mark(1024)
mark.save(path, "PNG", optimize=True)
print(f"[ok] mark -> {path}")
def make_horizontal(path: Path, text_color: tuple) -> None:
"""Layout: mark a sinistra + testo. Canvas si autodimensiona al testo."""
H = 720
mark_size = int(H * 0.92)
pad_left = 60
gap = 70
pad_right = 80
text = "MusicTools"
f = font(260, black=True)
tw, th = measure_text(text, f)
W = pad_left + mark_size + gap + tw + pad_right
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
# Mark a sinistra, centrato verticalmente
mark = load_mark(mark_size)
mark_y = (H - mark_size) // 2
canvas.alpha_composite(mark, (pad_left, mark_y))
# Testo allineato top-left calcolato in modo che la sua baseline
# sia visivamente centrata sull'altezza dello squircle.
text_x = pad_left + mark_size + gap
text_y = (H - th) // 2
draw_text_top_left(canvas, text, (text_x, text_y), f, text_color)
canvas.save(path, "PNG", optimize=True)
name = "dark" if text_color == WHITE else "light"
print(f"[ok] horizontal ({name}) -> {path} [{W}x{H}]")
def make_stacked(path: Path, text_color: tuple) -> None:
"""Mark sopra + 'MusicTools' sotto. Canvas autodimensionato al testo."""
mark_size = 1024
f = font(180, black=True)
text = "MusicTools"
tw, th = measure_text(text, f)
pad = 80
gap = 50
W = max(mark_size, tw) + 2 * pad
H = pad + mark_size + gap + th + pad
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
mark = load_mark(mark_size)
mark_x = (W - mark_size) // 2
canvas.alpha_composite(mark, (mark_x, pad))
text_x = (W - tw) // 2
text_y = pad + mark_size + gap
draw_text_top_left(canvas, text, (text_x, text_y), f, text_color)
canvas.save(path, "PNG", optimize=True)
name = "dark" if text_color == WHITE else "light"
print(f"[ok] stacked ({name}) -> {path} [{W}x{H}]")
def main() -> None:
make_mark(HERE / "logo_mark.png")
make_horizontal(HERE / "logo_horizontal_dark.png", WHITE) # per sfondi scuri
make_horizontal(HERE / "logo_horizontal_light.png", BLACK) # per sfondi chiari
make_stacked(HERE / "logo_stacked_dark.png", WHITE) # per sfondi scuri
make_stacked(HERE / "logo_stacked_light.png", BLACK) # per sfondi chiari
# rimuovo eventuale vecchio file senza suffisso
old = HERE / "logo_stacked.png"
if old.exists():
old.unlink()
if __name__ == "__main__":
main()
+296
View File
@@ -0,0 +1,296 @@
"""Genera le immagini prodotto per Lemon Squeezy.
Produce:
- ls_thumbnail.png 1024x1024 (thumbnail prodotto + checkout)
- ls_cover.png 1920x1080 (cover/hero per la pagina prodotto)
- ls_og.png 1200x630 (Open Graph per social share)
La nota musicale e' disegnata geometricamente (no glyph Unicode)
per essere indipendente dai font installati sul sistema.
"""
from __future__ import annotations
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
OUT_DIR = Path(__file__).resolve().parent
GREEN_LIGHT = (29, 185, 84)
GREEN_DARK = (19, 122, 55)
BG_TOP = (10, 50, 26)
BG_BOTTOM = (6, 6, 6)
WHITE = (255, 255, 255)
MUTED = (190, 190, 190)
ARIAL_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
ARIAL_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
ARIAL_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
def font(size: int, *, black: bool = False, bold: bool = True) -> ImageFont.FreeTypeFont:
path = ARIAL_BLACK if black else (ARIAL_BOLD if bold else ARIAL_REG)
try:
return ImageFont.truetype(path, size)
except OSError:
return ImageFont.load_default()
def vgradient(size: tuple[int, int], top: tuple, bottom: tuple) -> Image.Image:
"""Gradient verticale tra due colori (RGB)."""
w, h = size
img = Image.new("RGB", size, top)
px = img.load()
for y in range(h):
t = y / max(h - 1, 1)
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(w):
px[x, y] = (r, g, b)
return img
def radial_glow(size: tuple[int, int], center: tuple[int, int],
radius: int, color: tuple, opacity: int = 90) -> Image.Image:
"""Glow radiale soft (RGBA)."""
glow = Image.new("RGBA", size, (0, 0, 0, 0))
draw = ImageDraw.Draw(glow)
cx, cy = center
draw.ellipse(
[cx - radius, cy - radius, cx + radius, cy + radius],
fill=(*color, opacity),
)
return glow.filter(ImageFilter.GaussianBlur(radius // 3))
def draw_text_centered(canvas: Image.Image, text: str, center: tuple[int, int],
size_px: int, color=WHITE, *, black: bool = True,
bold: bool = True) -> None:
draw = ImageDraw.Draw(canvas, "RGBA")
f = font(size_px, black=black, bold=bold)
bbox = draw.textbbox((0, 0), text, font=f)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x = center[0] - tw // 2 - bbox[0]
y = center[1] - th // 2 - bbox[1]
draw.text((x, y), text, font=f, fill=(*color, 255))
def draw_text_left(canvas: Image.Image, text: str, anchor: tuple[int, int],
size_px: int, color=WHITE, *, black: bool = True,
bold: bool = True) -> None:
"""Disegna testo allineato a sinistra (anchor = baseline-left)."""
draw = ImageDraw.Draw(canvas, "RGBA")
f = font(size_px, black=black, bold=bold)
bbox = draw.textbbox(anchor, text, font=f)
draw.text(anchor, text, font=f, fill=(*color, 255))
def draw_pill(canvas: Image.Image, center: tuple[int, int], text: str,
pad_x: int, pad_y: int, font_size: int,
fill=(255, 255, 255, 30), text_color=WHITE,
*, bold: bool = True) -> None:
draw = ImageDraw.Draw(canvas, "RGBA")
f = font(font_size, bold=bold, black=False)
bbox = draw.textbbox((0, 0), text, font=f)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
pill_w = tw + 2 * pad_x
pill_h = th + 2 * pad_y
x0 = center[0] - pill_w // 2
y0 = center[1] - pill_h // 2
draw.rounded_rectangle(
[x0, y0, x0 + pill_w, y0 + pill_h],
radius=pill_h // 2,
fill=fill,
)
tx = center[0] - tw // 2 - bbox[0]
ty = center[1] - th // 2 - bbox[1]
draw.text((tx, ty), text, font=f, fill=(*text_color, 255))
def draw_music_note(canvas: Image.Image, center: tuple[int, int],
size_px: int, color=WHITE) -> None:
"""Disegna una nota musicale stilizzata (due crome connesse).
Geometria approssimativa:
- 2 teste ellittiche (sinistra e destra)
- 2 aste verticali sottili
- barra orizzontale curva in alto che le connette
Tutto scalato in funzione di size_px (larghezza disegno).
"""
cx, cy = center
W = size_px
head_w = int(W * 0.30) # larghezza testa
head_h = int(head_w * 0.74) # altezza testa (ovale schiacciata)
stem_w = max(int(W * 0.035), 6)
stem_h = int(W * 0.78)
spacing = int(W * 0.40) # distanza tra le 2 aste
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
# Posizioni delle 2 aste
left_stem_x = cx - spacing // 2
right_stem_x = cx + spacing // 2
top_y = cy - stem_h // 2
bot_y = cy + stem_h // 2
# Aste (rettangoli verticali)
d.rounded_rectangle(
[left_stem_x - stem_w // 2, top_y, left_stem_x + stem_w // 2, bot_y],
radius=stem_w // 2, fill=color + (255,),
)
d.rounded_rectangle(
[right_stem_x - stem_w // 2, top_y, right_stem_x + stem_w // 2, bot_y],
radius=stem_w // 2, fill=color + (255,),
)
# Barra orizzontale spessa in alto che unisce le aste (ramo)
bar_thick = max(int(W * 0.09), 14)
d.rounded_rectangle(
[left_stem_x - stem_w // 2 - 4, top_y,
right_stem_x + stem_w // 2 + 4, top_y + bar_thick],
radius=bar_thick // 3, fill=color + (255,),
)
# Seconda barra subito sotto (la nota doppia ha sempre 2 ramificazioni)
second_bar_y = top_y + int(bar_thick * 1.6)
d.rounded_rectangle(
[left_stem_x - stem_w // 2 - 4, second_bar_y,
right_stem_x + stem_w // 2 + 4, second_bar_y + int(bar_thick * 0.7)],
radius=bar_thick // 3, fill=color + (255,),
)
# Teste ellittiche: in basso, leggermente a sinistra dell'asta (inclinate)
def head_at(stem_x: int) -> None:
# Centro testa: poco sotto al fondo dell'asta, leggermente a sinistra
hx = stem_x - int(head_w * 0.42)
hy = bot_y - int(head_h * 0.20)
# Disegnata su layer rotato per dare l'inclinazione
tmp = Image.new("RGBA", (head_w + 40, head_h + 40), (0, 0, 0, 0))
td = ImageDraw.Draw(tmp)
td.ellipse([10, 10, 10 + head_w, 10 + head_h], fill=color + (255,))
rotated = tmp.rotate(-22, resample=Image.BICUBIC, expand=True)
rw, rh = rotated.size
layer.alpha_composite(rotated, (hx - rw // 2 + head_w // 2, hy - rh // 2 + head_h // 2))
head_at(left_stem_x)
head_at(right_stem_x)
# Glow morbido sotto la nota per profondita'
glow_src = layer.filter(ImageFilter.GaussianBlur(W // 22))
# Riduco un po' l'alpha del glow
alpha = glow_src.split()[3].point(lambda a: int(a * 0.45))
glow = Image.merge("RGBA", (*glow_src.split()[:3], alpha))
canvas.alpha_composite(glow)
canvas.alpha_composite(layer)
# ============================================================
# Thumbnail 1024x1024
# ============================================================
def make_thumbnail(path: Path) -> None:
W = H = 1024
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
img.alpha_composite(radial_glow((W, H), (W // 2, H // 2 - 80), 430, GREEN_LIGHT, 100))
# Eyebrow in cima (testo semplice, no pill)
draw_text_centered(img, "DESKTOP APP - MAC e WINDOWS", (W // 2, 165),
size_px=30, color=GREEN_LIGHT, black=False, bold=True)
# Nota geometrica centrale
draw_music_note(img, (W // 2, H // 2 - 40), 360, color=WHITE)
# Titolo grande
draw_text_centered(img, "MusicTools", (W // 2, H - 280),
size_px=110, color=WHITE, black=True)
# Tagline
draw_text_centered(img, "Scarica - Modifica - Registra", (W // 2, H - 195),
size_px=38, color=MUTED, black=False, bold=True)
# Pill prezzo in basso
draw_pill(img, (W // 2, H - 100), "Licenza a vita 39,90 EUR",
pad_x=26, pad_y=14, font_size=30,
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
img.convert("RGB").save(path, "PNG", optimize=True)
print(f"[ok] thumbnail -> {path}")
# ============================================================
# Cover/Hero 1920x1080
# ============================================================
def make_cover(path: Path) -> None:
W, H = 1920, 1080
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
# Glow grosso a sinistra (zona del testo)
img.alpha_composite(radial_glow((W, H), (W // 3, H // 2), 640, GREEN_LIGHT, 85))
# Glow secondario a destra (zona nota)
img.alpha_composite(radial_glow((W, H), (int(W * 0.78), H // 2), 380, GREEN_LIGHT, 55))
LEFT_X = 140
# Eyebrow testo semplice
draw_text_left(img, "DESKTOP APP", (LEFT_X, 220),
size_px=30, color=GREEN_LIGHT, black=False, bold=True)
# Heading 2 righe (sinistra)
draw_text_left(img, "La tua musica.", (LEFT_X, 310),
size_px=120, color=WHITE, black=True)
draw_text_left(img, "In un'unica app.", (LEFT_X, 460),
size_px=120, color=GREEN_LIGHT, black=True)
# Sub-tagline
draw_text_left(img, "Scarica da Spotify e YouTube.", (LEFT_X, 640),
size_px=42, color=MUTED, black=False, bold=True)
draw_text_left(img, "Registra audio. Modifica metadati.", (LEFT_X, 700),
size_px=42, color=MUTED, black=False, bold=True)
# Pill prezzo (sinistra, basso)
draw_pill(img, (LEFT_X + 320, H - 160), "Licenza a vita - 39,90 EUR",
pad_x=30, pad_y=18, font_size=40,
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
# Nota grande a destra
draw_music_note(img, (int(W * 0.78), H // 2 - 20), 540, color=WHITE)
img.convert("RGB").save(path, "PNG", optimize=True)
print(f"[ok] cover -> {path}")
# ============================================================
# OG image 1200x630
# ============================================================
def make_og(path: Path) -> None:
W, H = 1200, 630
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
img.alpha_composite(radial_glow((W, H), (W // 2, H // 2 - 50), 420, GREEN_LIGHT, 95))
# Nota in alto centro
draw_music_note(img, (W // 2, 180), 180, color=WHITE)
# Titolo
draw_text_centered(img, "MusicTools", (W // 2, 360),
size_px=92, color=WHITE, black=True)
# Tagline
draw_text_centered(img, "Scarica - Modifica - Registra", (W // 2, 440),
size_px=32, color=MUTED, black=False, bold=True)
# Pill prezzo in basso
draw_pill(img, (W // 2, H - 80), "Licenza a vita 39,90 EUR - Mac e Windows",
pad_x=24, pad_y=13, font_size=28,
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
img.convert("RGB").save(path, "PNG", optimize=True)
print(f"[ok] og -> {path}")
def main() -> None:
make_thumbnail(OUT_DIR / "ls_thumbnail.png")
make_cover(OUT_DIR / "ls_cover.png")
make_og(OUT_DIR / "ls_og.png")
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+254 -10
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Build script per creare MusicDownload.app su macOS. Build script per creare MusicTools.app su macOS.
Uso: Uso:
python3 build_macos.py python3 build_macos.py
@@ -8,7 +8,7 @@ Uso:
Cosa fa: Cosa fa:
1. Scarica il binario standalone di yt-dlp per macOS 1. Scarica il binario standalone di yt-dlp per macOS
2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew 2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew
3. Esegue PyInstaller per creare MusicDownload.app 3. Esegue PyInstaller per creare MusicTools.app
Requisiti: Requisiti:
pip3 install pyinstaller pip3 install pyinstaller
@@ -208,47 +208,291 @@ def run_pyinstaller():
if f.is_file(): if f.is_file():
add_binaries.extend(["--add-binary", f"{f}:lib"]) add_binaries.extend(["--add-binary", f"{f}:lib"])
icon_path = ROOT / "assets" / "icon.icns"
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--name", "MusicDownload", "--name", "MusicTools",
"--windowed", "--windowed",
"--onedir", "--onedir",
"--noconfirm", "--noconfirm",
]
if icon_path.exists():
cmd.extend(["--icon", str(icon_path)])
cmd.extend([
# Web UI assets (HTML/CSS/JS) — usati da pywebview # Web UI assets (HTML/CSS/JS) — usati da pywebview
"--add-data", f"{ROOT / 'webui'}:webui", "--add-data", f"{ROOT / 'webui'}:webui",
# Hidden imports per pywebview (WebKit su macOS) # Hidden imports per pywebview (WebKit su macOS)
"--hidden-import", "webview", "--hidden-import", "webview",
"--hidden-import", "webview.platforms.cocoa", "--hidden-import", "webview.platforms.cocoa",
"--hidden-import", "requests", "--hidden-import", "requests",
"--collect-all", "mutagen",
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs) # Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
*add_binaries, *add_binaries,
# Entry point # Entry point
str(ROOT / "main.py"), str(ROOT / "main.py"),
] ])
log(f"Comando: {' '.join(cmd[:10])}...") log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT)) subprocess.run(cmd, check=True, cwd=str(ROOT))
app_path = DIST_DIR / "MusicDownload.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)
# NOTA: dal v1.7.14 il microfono viene letto direttamente da
# PyObjC/AVCaptureSession nel processo Python (che ha il TCC del
# bundle), non da ffmpeg subprocess. ffmpeg fa solo la conversione
# CAF -> MP3 (no microfono = no TCC). Per questo non serve piu'
# incapsularlo in un sub-bundle .app con NSMicrophoneUsageDescription.
# 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")
else: else:
log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller") log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller")
# Potrebbe essere in dist/MusicDownload/ (non .app) # Potrebbe essere in dist/MusicTools/ (non .app)
alt = DIST_DIR / "MusicDownload" alt = DIST_DIR / "MusicTools"
if alt.exists(): if alt.exists():
log(f"Trovata directory: {alt}") log(f"Trovata directory: {alt}")
def patch_info_plist(app_path):
"""Aggiunge le chiavi TCC nell'Info.plist del bundle .app.
NSMicrophoneUsageDescription e' obbligatoria: senza, macOS uccide
l'app con SIGABRT quando AVFoundation tenta di aprire il microfono.
Usiamo plistlib invece di PlistBuddy perche' i valori con apostrofi
(es. "l'audio") rompono il parser shell-based di PlistBuddy.
"""
import plistlib
info_plist = app_path / "Contents" / "Info.plist"
if not info_plist.exists():
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
return
log("Patching Info.plist con le chiavi TCC...")
with open(info_plist, "rb") as f:
data = plistlib.load(f)
data["NSMicrophoneUsageDescription"] = (
"MusicTools usa il microfono per registrare l'audio del sistema "
"(ad esempio tramite BlackHole o un altro dispositivo loopback)."
)
# Bundle identifier stabile aiuta TCC a riconoscere l'app tra le sessioni
data["CFBundleIdentifier"] = "com.djluza.musictools"
data["LSApplicationCategoryType"] = "public.app-category.music"
with open(info_plist, "wb") as f:
plistlib.dump(data, f)
log(f"Info.plist aggiornato. Chiavi: {sorted(data.keys())[-5:]}")
# Verifica
with open(info_plist, "rb") as f:
verify = plistlib.load(f)
if "NSMicrophoneUsageDescription" not in verify:
log("ERRORE CRITICO: NSMicrophoneUsageDescription NON e' stata scritta!")
else:
log("Verifica OK: NSMicrophoneUsageDescription presente.")
_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
# ========================================================================= # =========================================================================
def main(): def main():
print("=" * 50) print("=" * 50)
print(" MusicDownload — Build macOS") print(" MusicTools — Build macOS")
print("=" * 50) print("=" * 50)
# Verifica pyinstaller # Verifica pyinstaller
@@ -271,12 +515,12 @@ def main():
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" DONE!") print(" DONE!")
print("=" * 50) print("=" * 50)
app = DIST_DIR / "MusicDownload.app" app = DIST_DIR / "MusicTools.app"
if app.exists(): if app.exists():
print(f"\n L'app e in: {app}") print(f"\n L'app e in: {app}")
print(f" Per testarla: open '{app}'") print(f" Per testarla: open '{app}'")
print(f"\n Per distribuirla, comprimi la cartella:") print(f"\n Per distribuirla, comprimi la cartella:")
print(f" zip -r MusicDownload.zip dist/MusicDownload.app") print(f" zip -r MusicTools.zip dist/MusicTools.app")
print() print()
+37 -13
View File
@@ -1,5 +1,5 @@
""" """
Build script per creare MusicDownload.exe su Windows. Build script per creare MusicTools.exe su Windows.
Uso: Uso:
python build_windows.py python build_windows.py
@@ -7,7 +7,7 @@ Uso:
Cosa fa: Cosa fa:
1. Scarica il binario standalone di yt-dlp per Windows 1. Scarica il binario standalone di yt-dlp per Windows
2. Scarica ffmpeg static build per Windows 2. Scarica ffmpeg static build per Windows
3. Esegue PyInstaller per creare MusicDownload.exe 3. Esegue PyInstaller per creare MusicTools.exe
Requisiti: Requisiti:
pip install pyinstaller pip install pyinstaller
@@ -99,37 +99,61 @@ def run_pyinstaller():
if f.is_file() and f.suffix in (".exe", ""): if f.is_file() and f.suffix in (".exe", ""):
add_binaries.extend(["--add-binary", f"{f};."]) add_binaries.extend(["--add-binary", f"{f};."])
icon_path = ROOT / "assets" / "icon.ico"
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--name", "MusicDownload", "--name", "MusicTools",
"--windowed", "--windowed",
"--onedir", "--onedir",
"--noconfirm", "--noconfirm",
# Icona (se presente) # Forza rebuild pulita (senza, residui di build vecchi possono
# "--icon", str(ROOT / "icon.ico"), # confondere il bundling di pythonnet/clr_loader).
"--clean",
]
if icon_path.exists():
cmd.extend(["--icon", str(icon_path)])
cmd.extend([
# Web UI assets (HTML/CSS/JS) — usati da pywebview # Web UI assets (HTML/CSS/JS) — usati da pywebview
"--add-data", f"{ROOT / 'webui'};webui", "--add-data", f"{ROOT / 'webui'};webui",
# 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-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
str(ROOT / "main.py"), str(ROOT / "main.py"),
] ])
log(f"Comando: {' '.join(cmd[:10])}...") log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT)) subprocess.run(cmd, check=True, cwd=str(ROOT))
exe_path = DIST_DIR / "MusicDownload" / "MusicDownload.exe" exe_path = DIST_DIR / "MusicTools" / "MusicTools.exe"
if exe_path.exists(): if exe_path.exists():
log(f"Build completata: {exe_path}") log(f"Build completata: {exe_path}")
else: else:
log("ATTENZIONE: MusicDownload.exe non trovato, controlla l'output") log("ATTENZIONE: MusicTools.exe non trovato, controlla l'output")
# ========================================================================= # =========================================================================
@@ -137,7 +161,7 @@ def run_pyinstaller():
# ========================================================================= # =========================================================================
def main(): def main():
print("=" * 50) print("=" * 50)
print(" MusicDownload - Build Windows") print(" MusicTools - Build Windows")
print("=" * 50) print("=" * 50)
if sys.platform != "win32": if sys.platform != "win32":
@@ -166,12 +190,12 @@ def main():
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" DONE!") print(" DONE!")
print("=" * 50) print("=" * 50)
dist = DIST_DIR / "MusicDownload" dist = DIST_DIR / "MusicTools"
if dist.exists(): if dist.exists():
print(f"\n L'app e in: {dist}") print(f"\n L'app e in: {dist}")
print(f" Eseguibile: {dist / 'MusicDownload.exe'}") print(f" Eseguibile: {dist / 'MusicTools.exe'}")
print(f"\n Per distribuirla, comprimi la cartella:") print(f"\n Per distribuirla, comprimi la cartella:")
print(f" Comprimi {dist} -> MusicDownload_Windows.zip") print(f" Comprimi {dist} -> MusicTools_Windows.zip")
print() print()
+48 -4
View File
@@ -5,24 +5,53 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.0" VERSION = "v1.7.15"
APP_NAME = "MusicTools"
_LEGACY_NAME = "MusicDownload"
# Endpoint dell'API di licenza/aggiornamenti. Cambialo qui per puntare
# a un ambiente di staging.
LICENSE_API_URL = "https://musictools.djluza.com"
# Quanti giorni puo' restare l'app offline prima di richiedere una
# nuova validazione contro il server.
LICENSE_GRACE_DAYS = 14
# Ogni quanti giorni l'app rivalida la licenza in background quando online.
LICENSE_REVALIDATE_DAYS = 7
def _get_config_dir() -> Path: def _get_config_dir() -> Path:
"""Ritorna la directory per config.json. """Ritorna la directory per config.json.
Frozen (bundle PyInstaller): Frozen (bundle PyInstaller):
- macOS: ~/Library/Application Support/MusicDownload/ - macOS: ~/Library/Application Support/MusicTools/
- Windows: %APPDATA%/MusicDownload/ - Windows: %APPDATA%/MusicTools/
Dev (non frozen): Dev (non frozen):
- directory del progetto - directory del progetto
Se la cartella nuova non esiste ma quella legacy 'MusicDownload' si',
viene migrata (copia di config.json) cosi' l'utente non perde le
impostazioni dopo il rename.
""" """
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
if sys.platform == "win32": if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", Path.home())) base = Path(os.environ.get("APPDATA", Path.home()))
else: else:
base = Path.home() / "Library" / "Application Support" base = Path.home() / "Library" / "Application Support"
config_dir = base / "MusicDownload" config_dir = base / APP_NAME
legacy_dir = base / _LEGACY_NAME
# Migrazione one-shot: se non esiste la nuova ma esiste la vecchia
if not config_dir.exists() and legacy_dir.exists():
try:
config_dir.mkdir(parents=True, exist_ok=True)
legacy_cfg = legacy_dir / "config.json"
if legacy_cfg.exists():
(config_dir / "config.json").write_bytes(legacy_cfg.read_bytes())
except OSError:
pass
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
return config_dir return config_dir
return Path(__file__).resolve().parent.parent return Path(__file__).resolve().parent.parent
@@ -41,6 +70,21 @@ DEFAULTS = {
"cookies_path": str(_project_dir / "cookies.txt"), "cookies_path": str(_project_dir / "cookies.txt"),
"output_dir": str(_project_dir / "MUSICA"), "output_dir": str(_project_dir / "MUSICA"),
"theme": "dark", "theme": "dark",
# ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto
"license_token": "", # JWT firmato dal server (claims offline)
"license_activated_at": 0, # epoch della prima attivazione
"last_validated_at": 0, # epoch dell'ultima revalidate online riuscita
"device_id": "", # UUID generato al primo avvio
# ---- Piano (snapshot dei claims JWT) ----
"plan_code": "", # "basic" | "pro" | "premium" | "annual"
"plan_name": "", # nome user-facing
"plan_features": [], # ["audio", "video", "record", "metadata", "upgrade"]
"plan_daily_limit": None, # None = unlimited (annual), altrimenti int
"plan_is_subscription": False,
"plan_expires_at": 0, # solo per annual one-time
"plan_period_end": 0, # solo per subscription mensili
} }
+43 -19
View File
@@ -9,7 +9,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
# Flag globale per interruzione # Flag globale per interruzione
@@ -102,7 +102,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}") raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
@@ -146,8 +146,10 @@ def download_playlist(
progress_callback(i, total, "", "stopped", 0) progress_callback(i, total, "", "stopped", 0)
return return
query = f"{track['name']} - {track['artist']}" artist = (track.get('artist') or "").strip()
track_only = track['name'] name = (track.get('name') or "").strip()
query = f"{name} - {artist}" if artist else name
track_only = name
# Salta brani gia scaricati in precedenza (file di tracking) # Salta brani gia scaricati in precedenza (file di tracking)
if query in done_set: if query in done_set:
@@ -204,6 +206,7 @@ def download_playlist(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
@@ -278,7 +281,7 @@ def download_direct_url(
probe_cmd.extend(["--cookies", cookies_path]) probe_cmd.extend(["--cookies", cookies_path])
try: try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60) result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(result.stderr.strip()) raise ValueError(result.stderr.strip())
except Exception as e: except Exception as e:
@@ -356,6 +359,7 @@ def download_direct_url(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
@@ -418,10 +422,14 @@ def download_video(
quality: str = "1080p", quality: str = "1080p",
cookies_path: Optional[str] = None, cookies_path: Optional[str] = None,
progress_callback: Optional[Callable] = None, progress_callback: Optional[Callable] = None,
audio_only: bool = False,
audio_bitrate: str = "320K",
) -> None: ) -> None:
"""Scarica video (con audio) da YouTube/TikTok/Instagram/Facebook/etc. """Scarica video (con audio) o solo audio da YouTube/TikTok/Instagram/Facebook/etc.
quality: "best" | "1080p" | "720p" | "480p" quality: "best" | "1080p" | "720p" | "480p" (ignorato se audio_only=True)
audio_only: True -> estrae solo audio in MP3
audio_bitrate: bitrate MP3 quando audio_only=True
Per Instagram/Facebook privati servono cookies validi. Per Instagram/Facebook privati servono cookies validi.
""" """
global _current_process global _current_process
@@ -439,7 +447,7 @@ def download_video(
probe_cmd.extend(["--cookies", cookies_path]) probe_cmd.extend(["--cookies", cookies_path])
try: try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60) result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(result.stderr.strip()) raise ValueError(result.stderr.strip())
except Exception as e: except Exception as e:
@@ -461,7 +469,7 @@ def download_video(
return return
total = len(entries) total = len(entries)
fmt = _video_format(quality) fmt = _video_format(quality) if not audio_only else "bestaudio/best"
for i, entry in enumerate(entries): for i, entry in enumerate(entries):
if is_stopped(): if is_stopped():
@@ -483,16 +491,31 @@ def download_video(
if progress_callback: if progress_callback:
progress_callback(i, total, title, "downloading", 0) progress_callback(i, total, title, "downloading", 0)
cmd = [ if audio_only:
ytdlp, cmd = [
"-f", fmt, ytdlp,
"--merge-output-format", "mp4", "-f", fmt,
"--add-metadata", "--extract-audio",
"--no-check-certificates", "--audio-format", "mp3",
"--newline", "--audio-quality", audio_bitrate.rstrip("Kk"),
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"), "--embed-thumbnail",
entry_url, "--add-metadata",
] "--no-check-certificates",
"--newline",
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
entry_url,
]
else:
cmd = [
ytdlp,
"-f", fmt,
"--merge-output-format", "mp4",
"--add-metadata",
"--no-check-certificates",
"--newline",
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
entry_url,
]
ffmpeg_dir = find_ffmpeg_dir() ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir: if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir]) cmd.extend(["--ffmpeg-location", ffmpeg_dir])
@@ -503,6 +526,7 @@ def download_video(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
+487
View File
@@ -0,0 +1,487 @@
"""Gestione licenze MusicTools.
Flusso:
1. L'utente compra su musictools.djluza.com -> riceve license_key via email.
2. Al primo avvio l'app mostra schermata bloccante: chiede email + key.
3. activate() chiama POST {LICENSE_API_URL}/api/license/activate
passando key, email, device_id (+ device_name). Il server:
- verifica che la key esista e non abbia superato max attivazioni;
- registra il device_id come attivo;
- ritorna un JWT con claims {sub: license_id, exp, email, key_id}.
4. Il token viene salvato in config.json. Il client lo considera valido
per LICENSE_REVALIDATE_DAYS senza ricontrollare il server; oltre
LICENSE_GRACE_DAYS chiede sempre nuova validazione online.
5. validate() chiama POST /api/license/validate con {token, device_id}
in background. Il server puo' revocare (refund, abuse) ritornando
401 -> il client cancella il token e torna a schermata attivazione.
Nota: non verifichiamo la firma JWT lato client (servirebbe la public
key). Ci fidiamo del token perche' e' uscito dal server al momento
dell'attivazione, e ogni N giorni lo facciamo rivalidare. Per i refund
non immediati basta il check periodico.
"""
from __future__ import annotations
import base64
import json
import platform
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from typing import Optional
from core.config import (
LICENSE_API_URL,
LICENSE_GRACE_DAYS,
LICENSE_REVALIDATE_DAYS,
VERSION,
load_config,
save_config,
)
_HTTP_TIMEOUT = 15 # secondi
# ============================================================
# Errori
# ============================================================
class LicenseError(Exception):
"""Errore di attivazione/validazione licenza."""
class LicenseNetworkError(LicenseError):
"""Impossibile raggiungere il server (offline, DNS, ecc.)."""
# ============================================================
# Utility
# ============================================================
def _now() -> int:
return int(time.time())
def _ensure_device_id(config: dict) -> str:
"""Garantisce che config abbia un device_id stabile e univoco."""
did = (config.get("device_id") or "").strip()
if not did:
did = str(uuid.uuid4())
config["device_id"] = did
save_config(config)
return did
def _device_name() -> str:
"""Nome leggibile per identificare il device lato server (UI utente)."""
try:
return f"{platform.node() or 'device'} ({platform.system()})"
except Exception:
return "device"
def _decode_jwt_claims(token: str) -> dict:
"""Decodifica i claims di un JWT senza verificare la firma.
Ritorna {} se il token e' malformato. La verifica vera e' fatta
dal server quando rivalidiamo online; qui ci serve solo leggere
exp/email per la UI.
"""
try:
parts = token.split(".")
if len(parts) != 3:
return {}
payload = parts[1]
# base64url -> base64 standard (padding)
payload += "=" * (-len(payload) % 4)
raw = base64.urlsafe_b64decode(payload.encode("ascii"))
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _http_post(path: str, body: dict) -> dict:
"""POST JSON verso LICENSE_API_URL{path}. Ritorna il JSON di risposta.
Solleva LicenseNetworkError per problemi di rete e LicenseError
per risposte HTTP 4xx/5xx (con messaggio dal server quando disponibile).
"""
url = LICENSE_API_URL.rstrip("/") + path
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers={
"Content-Type": "application/json",
"User-Agent": f"MusicTools/{VERSION}",
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode("utf-8")
err_data = json.loads(err_body)
msg = err_data.get("error") or err_data.get("message") or e.reason
except Exception:
msg = e.reason or f"HTTP {e.code}"
raise LicenseError(str(msg))
except urllib.error.URLError as e:
raise LicenseNetworkError(str(e.reason) if hasattr(e, "reason") else str(e))
except (TimeoutError, OSError) as e:
raise LicenseNetworkError(str(e))
# ============================================================
# API pubblica
# ============================================================
def _plan_snapshot(cfg: dict) -> dict:
"""Estrae i dati del piano dal config (popolati da activate/validate)."""
code = (cfg.get("plan_code") or "").strip()
if not code:
return {}
return {
"code": code,
"name": (cfg.get("plan_name") or "").strip() or code.title(),
"features": list(cfg.get("plan_features") or []),
"daily_limit": cfg.get("plan_daily_limit"), # puo' essere None
"is_subscription": bool(cfg.get("plan_is_subscription")),
"expires_at": int(cfg.get("plan_expires_at") or 0),
"period_end": int(cfg.get("plan_period_end") or 0),
}
def has_feature(name: str, config: Optional[dict] = None) -> bool:
"""True se il piano corrente include la feature richiesta."""
cfg = config or load_config()
plan = _plan_snapshot(cfg)
if not plan:
return False
return name in (plan.get("features") or [])
def get_plan(config: Optional[dict] = None) -> dict:
"""Ritorna il dict del piano corrente, oppure {} se non noto."""
return _plan_snapshot(config or load_config())
def get_status(config: Optional[dict] = None) -> dict:
"""Ritorna lo stato corrente della licenza per la UI.
Chiavi: licensed (bool), reason (str), email, key, activated_at,
last_validated_at, days_since_validation, expires_soon (bool),
needs_revalidation (bool), plan (dict).
"""
cfg = config or load_config()
token = (cfg.get("license_token") or "").strip()
key = (cfg.get("license_key") or "").strip()
email = (cfg.get("license_email") or "").strip()
activated = int(cfg.get("license_activated_at") or 0)
last_val = int(cfg.get("last_validated_at") or 0)
plan = _plan_snapshot(cfg)
if not token or not key:
return {
"licensed": False,
"reason": "not_activated",
"email": "",
"key": "",
"activated_at": 0,
"last_validated_at": 0,
"days_since_validation": 0,
"needs_revalidation": False,
"plan": plan,
}
# Scadenza locale derivata dai claims (best-effort: la verita' e' sul server).
t = _now()
expires_at = plan.get("expires_at") or 0
period_end = plan.get("period_end") or 0
if expires_at and expires_at < t:
return {
"licensed": False, "reason": "plan_expired", "email": email, "key": key,
"activated_at": activated, "last_validated_at": last_val,
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
}
if period_end and period_end < t:
return {
"licensed": False, "reason": "subscription_expired", "email": email, "key": key,
"activated_at": activated, "last_validated_at": last_val,
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
}
days_since = (t - last_val) // 86400 if last_val else 999
needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
grace_expired = days_since >= LICENSE_GRACE_DAYS
if grace_expired:
return {
"licensed": False,
"reason": "grace_expired",
"email": email,
"key": key,
"activated_at": activated,
"last_validated_at": last_val,
"days_since_validation": days_since,
"needs_revalidation": True,
"plan": plan,
}
return {
"licensed": True,
"reason": "ok",
"email": email,
"key": key,
"activated_at": activated,
"last_validated_at": last_val,
"days_since_validation": days_since,
"needs_revalidation": needs_reval,
"plan": plan,
}
def is_licensed() -> bool:
"""Helper rapido per gating delle azioni."""
return get_status()["licensed"]
def _store_plan_from_response(cfg: dict, resp: dict, token: str) -> None:
"""Aggiorna il config con i campi del piano estratti dalla risposta API.
Server risposta puo' contenere:
- plan: {code, name, daily_limit, features, is_subscription}
- expires_at: epoch (annual)
- period_end: epoch (subscription)
Come fallback decodifica i claims dal JWT.
"""
plan = resp.get("plan") if isinstance(resp.get("plan"), dict) else None
claims = _decode_jwt_claims(token) if token else {}
code = ""
name = ""
features = []
daily_limit = None
is_sub = False
if plan:
code = (plan.get("code") or "").strip()
name = (plan.get("name") or "").strip()
features = list(plan.get("features") or [])
daily_limit = plan.get("daily_limit")
is_sub = bool(plan.get("is_subscription"))
else:
code = (claims.get("plan") or "").strip()
name = (claims.get("plan_name") or "").strip()
features = list(claims.get("features") or [])
daily_limit = claims.get("daily_limit")
is_sub = bool(claims.get("is_subscription"))
cfg["plan_code"] = code
cfg["plan_name"] = name
cfg["plan_features"] = features
cfg["plan_daily_limit"] = daily_limit # None ammesso (unlimited)
cfg["plan_is_subscription"] = is_sub
cfg["plan_expires_at"] = int(resp.get("expires_at") or claims.get("expires_at") or 0)
cfg["plan_period_end"] = int(resp.get("period_end") or claims.get("period_end") or 0)
def _clear_plan(cfg: dict) -> None:
cfg["plan_code"] = ""
cfg["plan_name"] = ""
cfg["plan_features"] = []
cfg["plan_daily_limit"] = None
cfg["plan_is_subscription"] = False
cfg["plan_expires_at"] = 0
cfg["plan_period_end"] = 0
def activate(license_key: str, email: str) -> dict:
"""Attiva una licenza contro il server. Salva token su success.
Ritorna dict con le chiavi di get_status(). Solleva LicenseError
o LicenseNetworkError in caso di fallimento.
"""
key = (license_key or "").strip()
mail = (email or "").strip().lower()
if not key or not mail:
raise LicenseError("Inserisci email e chiave di licenza.")
cfg = load_config()
device_id = _ensure_device_id(cfg)
resp = _http_post("/api/license/activate", {
"key": key,
"email": mail,
"device_id": device_id,
"device_name": _device_name(),
"app_version": VERSION,
})
token = (resp.get("token") or "").strip()
if not token:
raise LicenseError(resp.get("error") or "Risposta server non valida.")
now = _now()
cfg["license_key"] = key
cfg["license_email"] = mail
cfg["license_token"] = token
cfg["license_activated_at"] = int(resp.get("activated_at") or now)
cfg["last_validated_at"] = now
_store_plan_from_response(cfg, resp, token)
save_config(cfg)
return get_status(cfg)
def validate() -> dict:
"""Rivalida il token corrente contro il server (background).
Aggiorna last_validated_at su success. Su 401 (revoca/refund)
azzera il token cosi' la prossima get_status ritorna not_activated.
Su errori di rete non fa nulla (resta valido fino al grace).
"""
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
if not token:
return get_status(cfg)
device_id = _ensure_device_id(cfg)
try:
resp = _http_post("/api/license/validate", {
"token": token,
"device_id": device_id,
"app_version": VERSION,
})
except LicenseNetworkError:
return get_status(cfg)
except LicenseError:
# Server ha risposto 4xx -> token non piu valido
cfg["license_token"] = ""
cfg["last_validated_at"] = 0
_clear_plan(cfg)
save_config(cfg)
return get_status(cfg)
# Server puo' inviare un token rinnovato (rotazione)
new_token = (resp.get("token") or "").strip()
if new_token:
cfg["license_token"] = new_token
cfg["last_validated_at"] = _now()
_store_plan_from_response(cfg, resp, cfg.get("license_token", ""))
save_config(cfg)
return get_status(cfg)
def deactivate(release_remote: bool = True) -> dict:
"""Disattiva la licenza su questo device.
Se release_remote, notifica il server cosi' libera lo slot
di attivazione (utile per spostare l'app su un altro device).
Sempre azzera i campi licenza locali, anche se il server e' offline.
"""
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
device_id = (cfg.get("device_id") or "").strip()
if release_remote and token and device_id:
try:
_http_post("/api/license/deactivate", {
"token": token,
"device_id": device_id,
})
except (LicenseError, LicenseNetworkError):
# Lo facciamo local-only se il server non risponde.
pass
cfg["license_key"] = ""
cfg["license_email"] = ""
cfg["license_token"] = ""
cfg["license_activated_at"] = 0
cfg["last_validated_at"] = 0
_clear_plan(cfg)
save_config(cfg)
return get_status(cfg)
# ============================================================
# Quota giornaliera
# ============================================================
def _http_json(method: str, path: str, body: Optional[dict] = None,
token: Optional[str] = None, timeout: int = 10) -> dict:
"""Helper unificato per GET/POST con Authorization Bearer."""
url = LICENSE_API_URL.rstrip("/") + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"User-Agent": f"MusicTools/{VERSION}",
"Accept": "application/json",
}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode("utf-8")
err_data = json.loads(err_body) if err_body else {}
except Exception:
err_data = {}
err_data.setdefault("error", e.reason or f"HTTP {e.code}")
err_data["_status"] = e.code
return err_data
except urllib.error.URLError as e:
raise LicenseNetworkError(str(e.reason) if hasattr(e, "reason") else str(e))
except (TimeoutError, OSError) as e:
raise LicenseNetworkError(str(e))
def get_quota_status() -> dict:
"""Legge lo stato quota dal server. Ritorna dict con plan/used/limit/remaining.
Solleva LicenseError se il server rifiuta (401/403); LicenseNetworkError
in caso di problemi di rete.
"""
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
if not token:
raise LicenseError("Licenza non attivata.")
resp = _http_json("GET", "/api/usage/status", token=token)
if resp.get("_status") and resp["_status"] >= 400:
raise LicenseError(resp.get("error") or "Errore stato quota.")
return resp
def consume_quota(feature: str = "") -> dict:
"""Incrementa la quota giornaliera prima di un download.
Ritorna {allowed, used, limit, remaining, plan, day}.
Se il server risponde 429 -> allowed=False (l'utente ha raggiunto il limite).
Per i piani unlimited (annual), allowed=True sempre.
Solleva LicenseError per 401/403; LicenseNetworkError per problemi di rete.
"""
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
if not token:
raise LicenseError("Licenza non attivata.")
body = {"feature": feature} if feature else {}
resp = _http_json("POST", "/api/usage/consume", body=body, token=token)
status_code = resp.pop("_status", 0)
if status_code == 401 or status_code == 403:
raise LicenseError(resp.get("error") or "Licenza non valida.")
if status_code == 429:
# Limite raggiunto, ma e' una risposta strutturata: ritorniamo
# il dict cosi' la UI puo' mostrare i numeri.
resp.setdefault("allowed", False)
return resp
if status_code and status_code >= 400:
raise LicenseError(resp.get("error") or f"Errore quota ({status_code}).")
resp.setdefault("allowed", True)
return resp
+520
View File
@@ -0,0 +1,520 @@
"""Lettura e scrittura metadati audio (ID3 per MP3, MP4 per M4A, Vorbis per FLAC)."""
from __future__ import annotations
import base64
import plistlib
import subprocess
import struct
import sys
from pathlib import Path
from typing import Optional
from mutagen import File as MutagenFile
from mutagen.id3 import (
ID3, ID3NoHeaderError,
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
)
from mutagen.mp4 import MP4, MP4Cover
from mutagen.flac import FLAC, Picture
from mutagen.wave import WAVE
SUPPORTED_EXTS = (".mp3", ".m4a", ".mp4", ".aac", ".flac", ".wav")
_WHERE_FROM_ATTR = "com.apple.metadata:kMDItemWhereFroms"
def _read_where_from(path: str) -> list:
"""Legge l'xattr macOS 'kMDItemWhereFroms' (lista di URL/origini).
Ritorna [] se non e macOS o se l'attributo non esiste."""
if sys.platform != "darwin":
return []
try:
res = subprocess.run(
["xattr", "-px", _WHERE_FROM_ATTR, path],
capture_output=True, text=True, timeout=5,
)
if res.returncode != 0:
return []
hex_str = "".join(res.stdout.split())
if not hex_str:
return []
raw = bytes.fromhex(hex_str)
items = plistlib.loads(raw)
if isinstance(items, list):
return [str(x) for x in items]
return [str(items)]
except Exception:
return []
def _write_where_from(path: str, urls: list) -> None:
"""Scrive l'xattr macOS 'kMDItemWhereFroms'. Lista vuota -> rimuove."""
if sys.platform != "darwin":
return
try:
clean = [u.strip() for u in (urls or []) if u and u.strip()]
if not clean:
subprocess.run(
["xattr", "-d", _WHERE_FROM_ATTR, path],
capture_output=True, timeout=5,
)
return
data = plistlib.dumps(clean, fmt=plistlib.FMT_BINARY)
subprocess.run(
["xattr", "-wx", _WHERE_FROM_ATTR, data.hex(), path],
capture_output=True, timeout=5, check=True,
)
except Exception:
pass
def _text(value) -> str:
"""Estrai testo da un frame mutagen."""
if value is None:
return ""
if hasattr(value, "text"):
t = value.text
if isinstance(t, list) and t:
return str(t[0])
return str(t)
if isinstance(value, list) and value:
return str(value[0])
return str(value)
def _read_mp3(path: str, result: dict) -> dict:
result["format"] = "MP3"
try:
tags = ID3(path)
except ID3NoHeaderError:
return result
result["title"] = _text(tags.get("TIT2"))
result["artist"] = _text(tags.get("TPE1"))
result["album_artist"] = _text(tags.get("TPE2"))
result["album"] = _text(tags.get("TALB"))
result["year"] = _text(tags.get("TDRC"))
result["track"] = _text(tags.get("TRCK"))
result["genre"] = _text(tags.get("TCON"))
result["bpm"] = _text(tags.get("TBPM"))
result["key"] = _text(tags.get("TKEY"))
for k in tags.keys():
if k.startswith("COMM"):
result["comment"] = _text(tags[k])
break
for k in tags.keys():
if k.startswith("APIC"):
apic = tags[k]
result["cover_base64"] = base64.b64encode(apic.data).decode("ascii")
result["cover_mime"] = apic.mime or "image/jpeg"
break
return result
def _read_mp4(path: str, result: dict) -> dict:
result["format"] = "M4A/MP4"
m = MP4(path)
tags = m.tags or {}
result["title"] = _text(tags.get("\xa9nam"))
result["artist"] = _text(tags.get("\xa9ART"))
result["album_artist"] = _text(tags.get("aART"))
result["album"] = _text(tags.get("\xa9alb"))
result["year"] = _text(tags.get("\xa9day"))
track_pair = tags.get("trkn")
if track_pair and isinstance(track_pair, list) and track_pair:
num, tot = track_pair[0] if isinstance(track_pair[0], tuple) else (track_pair[0], 0)
result["track"] = f"{num}/{tot}" if tot else str(num)
result["genre"] = _text(tags.get("\xa9gen"))
result["comment"] = _text(tags.get("\xa9cmt"))
result["bpm"] = _text(tags.get("tmpo"))
covers = tags.get("covr")
if covers:
c = covers[0]
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png"
return result
_WAV_INFO_MAP = {
"INAM": "title",
"IART": "artist",
"IPRD": "album",
"ICRD": "year",
"IYER": "year",
"IGNR": "genre",
"ICMT": "comment",
"ITRK": "track",
"IPRT": "track",
"TRCK": "track",
"IBPM": "bpm",
"TBPM": "bpm",
"TKEY": "key",
}
def _read_wav_info_chunk(path: str) -> dict:
"""Legge il chunk RIFF LIST/INFO di un file WAV.
Ritorna un dict con i campi noti. Usato come fallback quando
il WAV non contiene ID3 embedded."""
out: dict = {}
try:
with open(path, "rb") as f:
header = f.read(12)
if len(header) < 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE":
return out
while True:
hdr = f.read(8)
if len(hdr) < 8:
break
chunk_id, chunk_size = struct.unpack("<4sI", hdr)
if chunk_id == b"LIST":
list_type = f.read(4)
list_end = f.tell() + (chunk_size - 4)
if list_type == b"INFO":
while f.tell() < list_end:
sub = f.read(8)
if len(sub) < 8:
break
sub_id, sub_size = struct.unpack("<4sI", sub)
data = f.read(sub_size).rstrip(b"\x00")
if sub_size % 2 == 1:
f.read(1) # padding word-aligned
key = _WAV_INFO_MAP.get(sub_id.decode("ascii", errors="ignore"))
if not key or not data:
continue
for enc in ("utf-8", "latin-1"):
try:
out[key] = data.decode(enc).strip()
break
except UnicodeDecodeError:
continue
else:
f.seek(list_end)
else:
skip = chunk_size + (chunk_size % 2)
f.seek(skip, 1)
except Exception:
pass
return out
def _read_wav(path: str, result: dict) -> dict:
result["format"] = "WAV"
# Prima fallback: leggi il chunk INFO (formato RIFF originale)
info = _read_wav_info_chunk(path)
for k, v in info.items():
if v:
result[k] = v
# Poi leggi ID3 se presente; ha priorita sui valori INFO
w = WAVE(path)
tags = w.tags
if tags is None:
return result
def setif(key, frame_key):
v = _text(tags.get(frame_key))
if v:
result[key] = v
setif("title", "TIT2")
setif("artist", "TPE1")
setif("album_artist", "TPE2")
setif("album", "TALB")
setif("year", "TDRC")
setif("track", "TRCK")
setif("genre", "TCON")
setif("bpm", "TBPM")
setif("key", "TKEY")
for k in tags.keys():
if k.startswith("COMM"):
v = _text(tags[k])
if v:
result["comment"] = v
break
for k in tags.keys():
if k.startswith("APIC"):
apic = tags[k]
result["cover_base64"] = base64.b64encode(apic.data).decode("ascii")
result["cover_mime"] = apic.mime or "image/jpeg"
break
return result
def _read_flac(path: str, result: dict) -> dict:
result["format"] = "FLAC"
f = FLAC(path)
g = lambda k: (f.get(k) or [""])[0] if f.get(k) else ""
result["title"] = g("title")
result["artist"] = g("artist")
result["album_artist"] = g("albumartist")
result["album"] = g("album")
result["year"] = g("date")
result["track"] = g("tracknumber")
result["genre"] = g("genre")
result["bpm"] = g("bpm")
result["key"] = g("initialkey")
result["comment"] = g("comment")
if f.pictures:
pic = f.pictures[0]
result["cover_base64"] = base64.b64encode(pic.data).decode("ascii")
result["cover_mime"] = pic.mime or "image/jpeg"
return result
def read_metadata(path: str) -> dict:
"""Ritorna un dict con tutti i metadati leggibili dal file."""
p = Path(path)
if not p.exists():
raise FileNotFoundError(path)
ext = p.suffix.lower()
if ext not in SUPPORTED_EXTS:
raise ValueError(f"Formato non supportato: {ext}")
result = {
"path": str(p),
"filename": p.name,
"format": "",
"title": "", "artist": "", "album_artist": "",
"album": "", "year": "", "track": "",
"genre": "", "comment": "",
"bpm": "", "key": "",
"duration": 0, "bitrate": 0,
"cover_base64": "", "cover_mime": "",
"where_from": _read_where_from(str(p)),
"is_macos": sys.platform == "darwin",
}
audio = MutagenFile(str(p))
if audio is None:
raise ValueError(f"Impossibile leggere: {ext}")
info = getattr(audio, "info", None)
if info:
result["duration"] = int(getattr(info, "length", 0) or 0)
result["bitrate"] = int((getattr(info, "bitrate", 0) or 0) // 1000)
if ext == ".mp3":
return _read_mp3(str(p), result)
if ext in (".m4a", ".mp4", ".aac"):
return _read_mp4(str(p), result)
if ext == ".flac":
return _read_flac(str(p), result)
if ext == ".wav":
return _read_wav(str(p), result)
return result
def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
def set_or_del(key, frame_cls, value):
if value:
tags[key] = frame_cls(encoding=3, text=value)
elif key in tags:
del tags[key]
set_or_del("TIT2", TIT2, data.get("title", ""))
set_or_del("TPE1", TPE1, data.get("artist", ""))
set_or_del("TPE2", TPE2, data.get("album_artist", ""))
set_or_del("TALB", TALB, data.get("album", ""))
set_or_del("TDRC", TDRC, data.get("year", ""))
set_or_del("TRCK", TRCK, data.get("track", ""))
set_or_del("TCON", TCON, data.get("genre", ""))
set_or_del("TBPM", TBPM, data.get("bpm", ""))
set_or_del("TKEY", TKEY, data.get("key", ""))
# Rimuovi tutti i COMM esistenti
for k in list(tags.keys()):
if k.startswith("COMM"):
del tags[k]
comment = data.get("comment", "")
if comment:
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
# Cover
if remove_cover or cover_path:
for k in list(tags.keys()):
if k.startswith("APIC"):
del tags[k]
if cover_path:
cp = Path(cover_path)
if cp.exists():
ext = cp.suffix.lower()
mime = "image/png" if ext == ".png" else "image/jpeg"
with open(cp, "rb") as f:
cover_data = f.read()
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data))
tags.save(path, v2_version=3)
def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
m = MP4(path)
def set_or_del(key, value):
if value:
m[key] = value
elif key in m:
del m[key]
set_or_del("\xa9nam", data.get("title", ""))
set_or_del("\xa9ART", data.get("artist", ""))
set_or_del("aART", data.get("album_artist", ""))
set_or_del("\xa9alb", data.get("album", ""))
set_or_del("\xa9day", data.get("year", ""))
set_or_del("\xa9gen", data.get("genre", ""))
set_or_del("\xa9cmt", data.get("comment", ""))
track = data.get("track", "")
if track:
try:
parts = track.split("/")
num = int(parts[0])
tot = int(parts[1]) if len(parts) > 1 else 0
m["trkn"] = [(num, tot)]
except (ValueError, IndexError):
pass
elif "trkn" in m:
del m["trkn"]
bpm = data.get("bpm", "")
if bpm:
try:
m["tmpo"] = [int(bpm)]
except ValueError:
pass
elif "tmpo" in m:
del m["tmpo"]
if remove_cover or cover_path:
if "covr" in m:
del m["covr"]
if cover_path:
cp = Path(cover_path)
if cp.exists():
ext = cp.suffix.lower()
fmt = MP4Cover.FORMAT_PNG if ext == ".png" else MP4Cover.FORMAT_JPEG
with open(cp, "rb") as f:
cover_data = f.read()
m["covr"] = [MP4Cover(cover_data, imageformat=fmt)]
m.save()
def _write_flac(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
f = FLAC(path)
def set_or_del(key, value):
if value:
f[key] = value
elif key in f:
del f[key]
set_or_del("title", data.get("title", ""))
set_or_del("artist", data.get("artist", ""))
set_or_del("albumartist", data.get("album_artist", ""))
set_or_del("album", data.get("album", ""))
set_or_del("date", data.get("year", ""))
set_or_del("tracknumber", data.get("track", ""))
set_or_del("genre", data.get("genre", ""))
set_or_del("bpm", data.get("bpm", ""))
set_or_del("initialkey", data.get("key", ""))
set_or_del("comment", data.get("comment", ""))
if remove_cover or cover_path:
f.clear_pictures()
if cover_path:
cp = Path(cover_path)
if cp.exists():
ext = cp.suffix.lower()
pic = Picture()
with open(cp, "rb") as fh:
pic.data = fh.read()
pic.type = 3
pic.mime = "image/png" if ext == ".png" else "image/jpeg"
f.add_picture(pic)
f.save()
def _write_wav(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
w = WAVE(path)
if w.tags is None:
w.add_tags()
tags = w.tags
def set_or_del(key, frame_cls, value):
if value:
tags[key] = frame_cls(encoding=3, text=value)
elif key in tags:
del tags[key]
set_or_del("TIT2", TIT2, data.get("title", ""))
set_or_del("TPE1", TPE1, data.get("artist", ""))
set_or_del("TPE2", TPE2, data.get("album_artist", ""))
set_or_del("TALB", TALB, data.get("album", ""))
set_or_del("TDRC", TDRC, data.get("year", ""))
set_or_del("TRCK", TRCK, data.get("track", ""))
set_or_del("TCON", TCON, data.get("genre", ""))
set_or_del("TBPM", TBPM, data.get("bpm", ""))
set_or_del("TKEY", TKEY, data.get("key", ""))
for k in list(tags.keys()):
if k.startswith("COMM"):
del tags[k]
comment = data.get("comment", "")
if comment:
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
if remove_cover or cover_path:
for k in list(tags.keys()):
if k.startswith("APIC"):
del tags[k]
if cover_path:
cp = Path(cover_path)
if cp.exists():
ext_c = cp.suffix.lower()
mime = "image/png" if ext_c == ".png" else "image/jpeg"
with open(cp, "rb") as f:
cover_data = f.read()
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data))
w.save()
def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
remove_cover: bool = False) -> None:
"""Salva i metadati. Se remove_cover=True rimuove la copertina esistente
senza sostituirla. Se cover_path e fornito, la sostituisce.
Su macOS aggiorna anche l'xattr kMDItemWhereFroms se data['where_from']
e fornito (lista di stringhe)."""
ext = Path(path).suffix.lower()
if ext == ".mp3":
_write_mp3(path, data, cover_path, remove_cover)
elif ext in (".m4a", ".mp4", ".aac"):
_write_mp4(path, data, cover_path, remove_cover)
elif ext == ".flac":
_write_flac(path, data, cover_path, remove_cover)
elif ext == ".wav":
_write_wav(path, data, cover_path, remove_cover)
else:
raise ValueError(f"Formato non supportato: {ext}")
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
if "where_from" in data:
_write_where_from(path, data.get("where_from") or [])
+44 -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
@@ -48,6 +55,19 @@ def _exe(name: str) -> str:
return name return name
def subprocess_flags() -> dict:
"""Kwargs per subprocess.run/Popen che nascondono la console su Windows.
Senza CREATE_NO_WINDOW, ogni subprocess (yt-dlp, ffmpeg, ffprobe) lanciato
da un'app GUI PyInstaller apre una finestra cmd nera per la durata del
processo. Su macOS/Linux il flag non esiste, quindi ritorniamo dict vuoto.
"""
if _IS_WINDOWS:
# 0x08000000 == CREATE_NO_WINDOW
return {"creationflags": 0x08000000}
return {}
def find_ytdlp() -> str: def find_ytdlp() -> str:
"""Trova il path di yt-dlp. """Trova il path di yt-dlp.
@@ -83,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
+551
View File
@@ -0,0 +1,551 @@
"""Registrazione audio.
macOS: AVCaptureSession via PyObjC dentro al processo Python principale.
Il bundle MusicTools.app ha NSMicrophoneUsageDescription e il TCC viene
concesso al main, quindi AVFoundation registra senza problemi. ffmpeg
viene usato SOLO per convertire il file CAF a MP3 (no microfono = no TCC).
Windows: ffmpeg DirectShow (qui il problema TCC non esiste).
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ffmpeg, subprocess_flags
_IS_MAC = sys.platform == "darwin"
_IS_WIN = sys.platform == "win32"
# ============================================================
# Stato globale (un solo recording alla volta)
# ============================================================
_recording_lock = threading.Lock()
_recording_state: dict = {}
_stderr_buf: list = []
_stderr_lock = threading.Lock()
def is_recording() -> bool:
with _recording_lock:
return bool(_recording_state.get("active"))
def get_last_stderr(max_lines: int = 50) -> list[str]:
"""Espone le ultime righe di log diagnostico ai fini diagnostici.
Su macOS contiene messaggi del backend AVCaptureSession + stderr di
ffmpeg durante la conversione CAF->MP3. Su Windows contiene lo
stderr di ffmpeg per la registrazione DirectShow.
"""
with _stderr_lock:
return list(_stderr_buf[-max_lines:])
def _log_diag(line: str) -> None:
with _stderr_lock:
_stderr_buf.append(line)
if len(_stderr_buf) > 200:
del _stderr_buf[0]
_VIRTUAL_KEYWORDS = (
"blackhole", "soundflower", "loopback", "audio hijack",
"vb-cable", "vb-audio", "stereo mix", "what u hear",
"obs", "system audio",
)
def _is_virtual(name: str) -> bool:
n = (name or "").lower()
return any(k in n for k in _VIRTUAL_KEYWORDS)
# ============================================================
# Lista dispositivi
# ============================================================
def list_input_devices() -> list[dict]:
"""Ritorna la lista dei dispositivi audio di input.
Ogni voce: {"id": "...", "name": "...", "is_virtual": bool}.
Su macOS l'`id` e' l'uniqueID di AVCaptureDevice (stabile).
Su Windows e' il nome del device (richiesto da DirectShow).
"""
if _IS_MAC:
return _list_macos_avf()
if _IS_WIN:
ffmpeg = find_ffmpeg()
return _list_windows(ffmpeg) if ffmpeg else []
return []
def _list_macos_avf() -> list[dict]:
"""Enumera input audio via AVFoundation (no ffmpeg subprocess)."""
try:
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
except ImportError as e:
_log_diag(f"[avf] AVFoundation non disponibile: {e}")
return []
devices: list[dict] = []
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
try:
name = str(d.localizedName())
uid = str(d.uniqueID())
except Exception:
continue
devices.append({
"id": uid,
"name": name,
"is_virtual": _is_virtual(name),
})
return devices
def _list_windows(ffmpeg: str) -> list[dict]:
"""Parsa l'output di `ffmpeg -f dshow -list_devices true -i dummy`."""
try:
proc = subprocess.run(
[ffmpeg, "-hide_banner", "-f", "dshow",
"-list_devices", "true", "-i", "dummy"],
capture_output=True, text=True, timeout=10,
**subprocess_flags(),
)
except Exception:
return []
devices: list[dict] = []
in_audio = False
line_re = re.compile(r'"([^"]+)"\s*\((audio|video)\)', re.IGNORECASE)
name_re = re.compile(r'"([^"]+)"')
for raw in proc.stderr.splitlines():
line = raw.strip()
low = line.lower()
if "directshow audio devices" in low:
in_audio = True
continue
if "directshow video devices" in low:
in_audio = False
continue
m = line_re.search(line)
if m:
name, kind = m.group(1), m.group(2).lower()
if kind == "audio":
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
continue
if in_audio:
mn = name_re.search(line)
if mn:
name = mn.group(1)
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
seen = set()
out = []
for d in devices:
if d["id"] in seen:
continue
seen.add(d["id"])
out.append(d)
return out
# ============================================================
# Permesso microfono (macOS)
# ============================================================
def _ensure_macos_mic_permission() -> tuple[bool, str]:
"""Verifica/richiede il permesso microfono (TCC) per il processo corrente.
Ritorna (granted, reason).
"""
try:
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
except ImportError as e:
return False, f"AVFoundation non disponibile: {e}"
# 0=notDetermined, 1=restricted, 2=denied, 3=authorized
status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)
if status == 3:
return True, "authorized"
if status in (1, 2):
return False, (
"Permesso microfono negato.\n"
"Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
"Attiva MusicTools e riavvia l'app."
)
# notDetermined -> chiedi il prompt in modo sincrono.
ev = threading.Event()
granted = [False]
def cb(ok):
granted[0] = bool(ok)
ev.set()
AVCaptureDevice.requestAccessForMediaType_completionHandler_(AVMediaTypeAudio, cb)
# PyObjC pumpa il run loop automaticamente in attesa del callback.
ev.wait(60)
if not granted[0]:
return False, "Permesso microfono negato dall'utente."
return True, "granted"
# ============================================================
# Registrazione macOS via AVCaptureSession
# ============================================================
def _start_recording_macos(device_id: str, output_path: str, bitrate: str,
progress_callback: Optional[Callable]) -> dict:
from AVFoundation import (
AVCaptureSession, AVCaptureDevice, AVCaptureDeviceInput,
AVCaptureAudioFileOutput, AVMediaTypeAudio,
)
from Foundation import NSURL, NSObject
ok, reason = _ensure_macos_mic_permission()
if not ok:
return {"ok": False, "error": reason}
# Trova il device: prima per uniqueID, poi per nome (fallback)
dev = AVCaptureDevice.deviceWithUniqueID_(device_id)
if dev is None:
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
if str(d.localizedName()) == device_id:
dev = d
break
if dev is None:
return {"ok": False, "error": "Dispositivo audio non trovato."}
session = AVCaptureSession.alloc().init()
inp, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(dev, None)
if err is not None:
return {"ok": False, "error": f"Errore input: {err.localizedDescription()}"}
if not session.canAddInput_(inp):
return {"ok": False, "error": "Impossibile aggiungere il device alla session."}
session.addInput_(inp)
out = AVCaptureAudioFileOutput.alloc().init()
if not session.canAddOutput_(out):
return {"ok": False, "error": "Impossibile aggiungere l'output alla session."}
session.addOutput_(out)
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
caf_path = out_path.with_suffix(".caf")
# Delegate: aspetta che lo stop produca il file finale
class _Delegate(NSObject):
def captureOutput_didStartRecordingToOutputFileAtURL_fromConnections_(
self, output, url, conns):
self.started = True
def captureOutput_didFinishRecordingToOutputFileAtURL_fromConnections_error_(
self, output, url, conns, err):
self.done = True
self.err = err.localizedDescription() if err is not None else None
delegate = _Delegate.alloc().init()
delegate.started = False
delegate.done = False
delegate.err = None
session.startRunning()
out.startRecordingToOutputFileURL_outputFileType_recordingDelegate_(
NSURL.fileURLWithPath_(str(caf_path)),
"com.apple.coreaudio-format",
delegate,
)
with _recording_lock:
_recording_state.update({
"active": True,
"session": session,
"output": out,
"delegate": delegate,
"caf_path": str(caf_path),
"mp3_path": str(out_path),
"bitrate": bitrate,
"started_at": time.monotonic(),
"progress_cb": progress_callback,
})
if progress_callback:
progress_callback("started", {"output_path": str(out_path)})
# Tick thread per emettere "seconds" elapsed (la UI lo mostra)
threading.Thread(target=_tick_worker, daemon=True).start()
_log_diag(f"[avf] Recording started -> {caf_path}")
return {"ok": True, "output_path": str(out_path)}
def _tick_worker():
while is_recording():
with _recording_lock:
cb = _recording_state.get("progress_cb")
started_at = _recording_state.get("started_at", time.monotonic())
elapsed = int(time.monotonic() - started_at)
if cb:
cb("tick", {"seconds": elapsed})
time.sleep(1.0)
def _stop_recording_macos() -> dict:
with _recording_lock:
if not _recording_state.get("active"):
return {"ok": True}
state = dict(_recording_state)
_recording_state["active"] = False
session = state["session"]
out = state["output"]
delegate = state["delegate"]
caf_path = Path(state["caf_path"])
mp3_path = Path(state["mp3_path"])
bitrate = state["bitrate"]
cb = state["progress_cb"]
started_at = state["started_at"]
# Stop recording (chiama il delegate didFinish dopo aver finalizzato il file)
out.stopRecording()
# Aspetta il completamento del file
deadline = time.time() + 8
while not getattr(delegate, "done", False) and time.time() < deadline:
time.sleep(0.1)
session.stopRunning()
if getattr(delegate, "err", None):
_log_diag(f"[avf] delegate error: {delegate.err}")
seconds = int(time.monotonic() - started_at)
# Conversione CAF -> MP3 via ffmpeg (NO microfono: niente TCC)
if caf_path.exists():
ffmpeg = find_ffmpeg()
if ffmpeg:
try:
proc = subprocess.run(
[ffmpeg, "-hide_banner", "-y", "-i", str(caf_path),
"-acodec", "libmp3lame", "-b:a", bitrate,
"-vn", str(mp3_path)],
capture_output=True, text=True, timeout=120,
**subprocess_flags(),
)
if proc.returncode != 0:
_log_diag(f"[ffmpeg-convert] rc={proc.returncode}")
for ln in (proc.stderr or "").splitlines()[-10:]:
_log_diag(ln)
# Cleanup CAF temporaneo
try:
caf_path.unlink()
except OSError:
pass
except Exception as e:
_log_diag(f"[ffmpeg-convert] exception: {e}")
else:
_log_diag("[ffmpeg-convert] ffmpeg non trovato, lascio il file CAF.")
# Se ffmpeg non c'e', rinomina il CAF in MP3 (estensione errata
# ma l'utente ha qualcosa)
try:
caf_path.rename(mp3_path.with_suffix(".caf"))
except OSError:
pass
if cb:
if mp3_path.exists():
cb("stopped", {"output_path": str(mp3_path), "seconds": seconds})
else:
cb("error", {"error": "File registrato non trovato dopo lo stop."})
return {"ok": True}
# ============================================================
# Registrazione Windows via ffmpeg DirectShow (invariato)
# ============================================================
def _start_recording_windows(device_id: str, output_path: str, bitrate: str,
progress_callback: Optional[Callable]) -> dict:
ffmpeg = find_ffmpeg()
if not ffmpeg:
return {"ok": False, "error": "ffmpeg non trovato"}
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
cmd = [
ffmpeg, "-hide_banner", "-y",
"-f", "dshow",
"-i", f"audio={device_id}",
"-acodec", "libmp3lame",
"-b:a", bitrate,
"-vn",
str(out),
]
with _stderr_lock:
_stderr_buf.clear()
def _drain(proc):
try:
for raw in iter(proc.stderr.readline, b""):
if not raw:
break
line = raw.decode("utf-8", errors="ignore").rstrip()
_log_diag(line)
except Exception:
pass
def _worker():
try:
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=False,
**subprocess_flags(),
)
with _recording_lock:
_recording_state.update({
"active": True,
"proc": proc,
"output_path": str(out),
"started_at": time.monotonic(),
"progress_cb": progress_callback,
})
threading.Thread(target=_drain, args=(proc,), daemon=True).start()
start = time.monotonic()
opened = False
for _ in range(15):
if proc.poll() is not None:
break
with _stderr_lock:
if any("Press [q]" in l for l in _stderr_buf):
opened = True
break
time.sleep(0.1)
if not opened and proc.poll() is not None:
with _recording_lock:
_recording_state["active"] = False
if progress_callback:
progress_callback("error", {
"error": _extract_friendly_error_windows(proc.returncode),
})
return
if progress_callback:
progress_callback("started", {"output_path": str(out)})
while True:
with _recording_lock:
if not _recording_state.get("active"):
break
rc = proc.poll()
if rc is not None:
break
elapsed = int(time.monotonic() - start)
if progress_callback:
progress_callback("tick", {"seconds": elapsed})
time.sleep(1.0)
if proc.poll() is None:
try:
proc.stdin.write(b"q\n")
proc.stdin.flush()
except Exception:
pass
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
seconds = int(time.monotonic() - start)
with _recording_lock:
_recording_state["active"] = False
if progress_callback:
if proc.returncode == 0 or out.exists():
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
else:
progress_callback("error", {
"error": _extract_friendly_error_windows(proc.returncode),
})
except Exception as e:
with _recording_lock:
_recording_state["active"] = False
if progress_callback:
progress_callback("error", {"error": f"{type(e).__name__}: {e}"})
threading.Thread(target=_worker, daemon=True).start()
return {"ok": True, "output_path": str(out)}
def _extract_friendly_error_windows(rc: int) -> str:
with _stderr_lock:
lines = list(_stderr_buf)
text = "\n".join(lines).lower()
if "no such device" in text or "could not find" in text:
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona."
if "permission" in text or "denied" in text:
return "Permesso microfono mancante. Impostazioni Windows -> Privacy -> Microfono."
for line in reversed(lines):
l = line.strip()
if not l:
continue
low = l.lower()
if "error" in low or "fail" in low or "denied" in low:
return l
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
def _stop_recording_windows() -> dict:
with _recording_lock:
if not _recording_state.get("active"):
return {"ok": True}
# Il worker thread vede 'active'=False e termina ffmpeg con 'q'.
_recording_state["active"] = False
return {"ok": True}
# ============================================================
# API pubblica
# ============================================================
def start_recording(
device_id: str,
output_path: str,
bitrate: str = "320k",
progress_callback: Optional[Callable] = None,
) -> dict:
if is_recording():
return {"ok": False, "error": "Una registrazione e gia in corso"}
with _stderr_lock:
_stderr_buf.clear()
if _IS_MAC:
return _start_recording_macos(device_id, output_path, bitrate, progress_callback)
if _IS_WIN:
return _start_recording_windows(device_id, output_path, bitrate, progress_callback)
return {"ok": False, "error": "Sistema operativo non supportato per la registrazione"}
def stop_recording() -> dict:
if _IS_MAC:
return _stop_recording_macos()
if _IS_WIN:
return _stop_recording_windows()
return {"ok": True}
+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]
+6 -3
View File
@@ -9,7 +9,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_flags
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"} AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
@@ -61,6 +61,7 @@ def get_bitrate(filepath: str | Path) -> int:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30, timeout=30,
**subprocess_flags(),
) )
data = json.loads(result.stdout) data = json.loads(result.stdout)
bit_rate = int(data.get("format", {}).get("bit_rate", 0)) bit_rate = int(data.get("format", {}).get("bit_rate", 0))
@@ -94,7 +95,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> str:
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError("Ricerca fallita") raise ValueError("Ricerca fallita")
@@ -128,7 +129,7 @@ def update_cover_only(
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
subprocess.run(cmd, capture_output=True, timeout=60) subprocess.run(cmd, capture_output=True, timeout=60, **subprocess_flags())
cover_files = list(temp_dir.glob("cover*.jpg")) cover_files = list(temp_dir.glob("cover*.jpg"))
if not cover_files: if not cover_files:
@@ -152,6 +153,7 @@ def update_cover_only(
], ],
capture_output=True, capture_output=True,
timeout=60, timeout=60,
**subprocess_flags(),
) )
except Exception: except Exception:
_cleanup_temp(temp_dir) _cleanup_temp(temp_dir)
@@ -284,6 +286,7 @@ def upgrade_folder(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
+72
View File
@@ -0,0 +1,72 @@
# MusicTools — Landing page
Sito vetrina + checkout per MusicTools, deployato su Cloudflare Pages al dominio `musictools.djluza.com`.
## Struttura
```
landing/
index.html # landing principale
css/style.css # tema dark coerente con l'app
js/main.js # FAQ accordion, animazioni scroll, hook Lemon Squeezy
assets/ # favicon, immagini eventuali
legal/
terms.html # termini di servizio
privacy.html # informativa privacy GDPR
refund.html # politica rimborsi
_headers # security + cache (Cloudflare Pages syntax)
_redirects # redirect rules
```
Nessun build step. Tutto e' HTML/CSS/JS statico servito così com'è.
## Anteprima locale
```bash
cd landing
python3 -m http.server 8080
# apri http://localhost:8080
```
Su `file://` Lemon Squeezy overlay non funziona — ma il sito si vede tutto.
## Deploy su Cloudflare Pages
**Una volta sola:**
1. Vai su <https://dash.cloudflare.com> → **Workers & Pages** → **Create application** → **Pages** → **Connect to Git**.
2. Scegli il repo `luzadev/musicdownload`.
3. Configura:
- **Production branch**: `main`
- **Build command**: *(lascia vuoto)*
- **Build output directory**: `landing`
- **Root directory**: *(lascia vuoto)*
4. Salva. La build parte automatica — l'URL iniziale sara' `<project>.pages.dev`.
5. Una volta verde, vai su **Custom domains** → **Set up a custom domain** → digita `musictools.djluza.com` → conferma.
6. Cloudflare crea automaticamente il CNAME se il dominio e' gia su Cloudflare DNS.
Ogni `git push origin main` rilancia la build di Pages — push-to-deploy gratis.
## Routing /api/* vs landing
Il Worker di `server/` deve essere associato alla route `musictools.djluza.com/api/*` (vedi `server/README.md`).
Pages serve tutto il resto. Verifica nel dashboard:
- **Workers & Pages** → `musictools-api` → **Settings** → **Triggers**:
- Aggiungi route `musictools.djluza.com/api/*`
- **Pages** → `musictools-landing` (o nome che scegli) → **Custom domains**:
- `musictools.djluza.com`
Cloudflare applica prima la regola del Worker (path-specific) e poi quella di Pages (catch-all).
## Da fare prima del lancio
- [ ] Sostituisci `REPLACE_WITH_LS_PRODUCT_URL` in `index.html` con l'URL prodotto Lemon Squeezy
- [ ] Crea immagine OG 1200x630 e referenziarla nei meta `og:image`
- [ ] Aggiungi screenshot reali (sostituiscono il mockup CSS nell'hero se preferisci)
- [ ] Verifica i tre documenti legali con un consulente fiscale se vendi in regime di forfettario / partita IVA italiana
- [ ] Aggiungi Google Search Console (verification meta) per indicizzazione
## Analytics
Puoi aggiungere Cloudflare Web Analytics (gratuito, no cookie) dal dashboard Pages → Analytics. Si attiva con un click — niente codice da incollare.
+17
View File
@@ -0,0 +1,17 @@
# Sicurezza + cache base.
# Sintassi Cloudflare Pages: https://developers.cloudflare.com/pages/configuration/headers/
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: interest-cohort=()
/css/*
Cache-Control: public, max-age=31536000, immutable
/js/*
Cache-Control: public, max-age=31536000, immutable
/assets/*
Cache-Control: public, max-age=2592000
+7
View File
@@ -0,0 +1,7 @@
# Redirects (sintassi Cloudflare Pages, Netlify-style).
# Niente regola per /api/* qui: lo gestisce il Worker (route definita
# nel dashboard Cloudflare). Pages serve il resto.
# Quando implementeremo /api/download (stream firmato R2->client), abilitare:
# /download/macos /api/download/macos 302
# /download/windows /api/download/windows 302
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

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1db954"/>
<stop offset="100%" stop-color="#137a37"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<text x="32" y="44" font-family="-apple-system, Segoe UI, sans-serif" font-weight="800" font-size="36" text-anchor="middle" fill="#fff">♫</text>
</svg>

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+921
View File
@@ -0,0 +1,921 @@
/* ============================================================
MusicTools — Landing page
Palette coerente con l'app: tema scuro Spotify-like.
============================================================ */
:root {
--bg: #0a0a0a;
--bg-2: #060606;
--bg-card: #161616;
--bg-card-2: #1e1e1e;
--border: #262626;
--divider: #1f1f1f;
--text: #ffffff;
--text-muted: #a3a3a3;
--text-dim: #707070;
--accent: #1db954;
--accent-dark: #137a37;
--accent-glow: rgba(29, 185, 84, 0.35);
--pink: #f472b6;
--blue: #60a5fa;
--amber: #fbbf24;
--red: #f87171;
--purple: #a78bfa;
}
* { box-sizing: border-box; }
*::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
font-size: 16px;
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img, svg { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }
.container {
max-width: 1180px;
margin: 0 auto;
padding: 0 24px;
}
/* ============================================================
NAV
============================================================ */
.nav {
position: sticky;
top: 0;
z-index: 100;
background: rgba(10, 10, 10, 0.82);
backdrop-filter: saturate(180%) blur(14px);
-webkit-backdrop-filter: saturate(180%) blur(14px);
border-bottom: 1px solid var(--border);
}
.nav-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
gap: 24px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
font-size: 17px;
}
.brand-logo {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
color: #fff;
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;
font-size: 14.5px;
color: var(--text-muted);
}
.nav-links a:hover { color: var(--text); }
/* ============================================================
BUTTONS
============================================================ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
font-weight: 700;
border: none;
border-radius: 999px;
cursor: pointer;
padding: 12px 22px;
font-size: 14px;
transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease;
white-space: nowrap;
}
.btn:active { transform: scale(0.98); }
.btn-primary {
background: var(--accent);
color: #0a0a0a;
box-shadow: 0 8px 24px var(--accent-glow);
}
.btn-primary:hover { background: #25d365; }
.btn-ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn-ghost:hover { background: var(--bg-card); border-color: #3a3a3a; }
.btn-sm { padding: 8px 16px; font-size: 13px; }
.btn-lg { padding: 16px 32px; font-size: 16px; }
.btn-block { width: 100%; }
/* ============================================================
HERO
============================================================ */
.hero {
position: relative;
padding: 80px 0 100px;
overflow: hidden;
background:
radial-gradient(ellipse 60% 50% at 0% 0%, rgba(29, 185, 84, 0.18), transparent 60%),
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(96, 165, 250, 0.12), transparent 60%),
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
}
.hero-grid {
display: grid;
grid-template-columns: 1.05fr 1fr;
gap: 60px;
align-items: center;
}
.eyebrow {
display: inline-block;
font-size: 11.5px;
font-weight: 800;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--accent);
padding: 5px 11px;
border: 1px solid rgba(29, 185, 84, 0.35);
border-radius: 999px;
background: rgba(29, 185, 84, 0.08);
}
.eyebrow.center {
display: block;
width: max-content;
margin: 0 auto;
}
.hero h1 {
margin: 18px 0 18px;
font-size: clamp(36px, 5.2vw, 58px);
font-weight: 900;
line-height: 1.04;
letter-spacing: -0.02em;
}
.accent { color: var(--accent); }
.lead {
font-size: 17px;
color: var(--text-muted);
line-height: 1.6;
max-width: 520px;
margin: 0 0 28px;
}
.hero-cta {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.hero-bullets {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
font-size: 13.5px;
color: var(--text-muted);
}
.hero-bullets li { display: flex; align-items: center; gap: 6px; }
/* ============================================================
HERO MOCKUP
============================================================ */
.hero-art {
position: relative;
perspective: 1200px;
}
.mockup {
width: 100%;
max-width: 560px;
margin-left: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 40px 100px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.02);
transform: rotate3d(0, 1, 0, -6deg) translateY(0);
transition: transform 0.4s ease;
}
.mockup:hover { transform: rotate3d(0, 1, 0, -3deg); }
.mockup-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 11px 14px;
background: #111111;
border-bottom: 1px solid var(--border);
position: relative;
}
.dot { width: 11px; height: 11px; border-radius: 50%; }
.dot.red { background: #ff5f57; }
.dot.yellow { background: #febc2e; }
.dot.green { background: #28c840; }
.mockup-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: var(--text-dim);
}
.mockup-body {
display: grid;
grid-template-columns: 168px 1fr;
min-height: 380px;
}
.mockup-side {
background: #111;
border-right: 1px solid var(--border);
padding: 16px 12px;
}
.ms-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
margin-bottom: 18px;
padding: 0 6px;
}
.ms-brand span {
width: 22px; height: 22px; border-radius: 7px;
display: inline-flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: #fff; font-size: 12px;
}
.ms-nav { display: flex; flex-direction: column; gap: 2px; }
.ms-item {
display: flex; align-items: center; gap: 10px;
padding: 7px 9px; border-radius: 8px;
font-size: 12.5px; color: var(--text-muted);
}
.ms-item.active { background: rgba(29, 185, 84, 0.14); color: #fff; }
.ms-item span { width: 18px; text-align: center; font-size: 13px; }
.mockup-main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.mockup-hero {
position: relative;
padding: 18px 20px;
border-radius: 12px;
background: linear-gradient(135deg, #1db954 0%, #0e7a35 100%);
color: #fff;
overflow: hidden;
}
.mh-eyebrow { font-size: 9.5px; letter-spacing: 1.5px; font-weight: 800; opacity: 0.85; }
.mh-title { font-size: 17px; font-weight: 800; margin-top: 4px; }
.mh-sub { font-size: 11px; opacity: 0.85; margin-top: 3px; }
.mh-deco { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 36px; opacity: 0.3; }
.mockup-card {
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: 11px;
padding: 12px 14px;
}
.mc-label { font-size: 9.5px; letter-spacing: 0.8px; color: var(--text-dim); text-transform: uppercase; font-weight: 700; }
.mc-input {
margin-top: 6px;
padding: 8px 10px;
background: #0d0d0d;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 11.5px;
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mockup-actions { display: flex; flex-direction: column; gap: 10px; }
.mc-btn.primary {
background: var(--accent);
color: #0a0a0a;
font-size: 12px;
font-weight: 700;
padding: 9px 0;
border-radius: 999px;
text-align: center;
}
.mc-progress {
height: 6px;
border-radius: 999px;
background: #0d0d0d;
overflow: hidden;
}
.mc-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #25d365);
border-radius: inherit;
animation: progress-pulse 2.5s ease-in-out infinite alternate;
}
@keyframes progress-pulse {
0% { width: 40%; }
100% { width: 78%; }
}
/* ============================================================
SECTIONS
============================================================ */
.section {
padding: 100px 0;
position: relative;
}
.section-title {
font-size: clamp(28px, 3.8vw, 42px);
font-weight: 900;
letter-spacing: -0.02em;
text-align: center;
margin: 14px 0 12px;
}
.section-lead {
text-align: center;
color: var(--text-muted);
font-size: 16px;
max-width: 620px;
margin: 0 auto 48px;
}
/* ============================================================
FEATURES
============================================================ */
.features { background: var(--bg-2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.feat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.feat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px 22px;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.25s ease;
}
.feat:hover {
transform: translateY(-4px);
border-color: #383838;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
}
.feat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 14px;
}
.feat.green .feat-icon { background: rgba(29, 185, 84, 0.16); color: var(--accent); }
.feat.pink .feat-icon { background: rgba(244, 114, 182, 0.16); color: var(--pink); }
.feat.blue .feat-icon { background: rgba(96, 165, 250, 0.16); color: var(--blue); }
.feat.amber .feat-icon { background: rgba(251, 191, 36, 0.16); color: var(--amber); }
.feat.red .feat-icon { background: rgba(248, 113, 113, 0.16); color: var(--red); }
.feat.purple .feat-icon { background: rgba(167, 139, 250, 0.16); color: var(--purple); }
.feat h3 {
font-size: 17px;
font-weight: 800;
margin: 0 0 6px;
}
.feat p {
font-size: 14px;
color: var(--text-muted);
margin: 0;
line-height: 1.55;
}
/* ============================================================
HOW IT WORKS
============================================================ */
.how-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 12px;
}
.how-step {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 28px;
text-align: center;
}
.how-num {
width: 44px;
height: 44px;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: #fff;
font-weight: 900;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
box-shadow: 0 10px 24px var(--accent-glow);
}
.how-step h3 {
font-size: 17px;
margin: 0 0 6px;
font-weight: 800;
}
.how-step p {
color: var(--text-muted);
font-size: 14px;
margin: 0;
}
/* ============================================================
PRICING
============================================================ */
.pricing { background: var(--bg-2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.price-card {
max-width: 480px;
margin: 0 auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 22px;
padding: 36px 32px 32px;
position: relative;
text-align: center;
box-shadow: 0 30px 80px rgba(29, 185, 84, 0.12);
}
.price-badge {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
color: #0a0a0a;
font-size: 11px;
letter-spacing: 1px;
font-weight: 800;
padding: 6px 12px;
border-radius: 999px;
text-transform: uppercase;
}
.price-name {
font-size: 13px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 700;
}
.price-row {
display: flex;
align-items: baseline;
justify-content: center;
gap: 6px;
margin: 14px 0 24px;
}
.price-amount {
font-size: 64px;
font-weight: 900;
line-height: 1;
letter-spacing: -0.03em;
}
.price-currency { font-size: 26px; font-weight: 700; color: var(--text-muted); }
.price-once { font-size: 14px; color: var(--text-muted); margin-left: 8px; }
.price-features {
list-style: none;
padding: 0;
margin: 0 0 28px;
text-align: left;
}
.price-features li {
padding: 9px 0;
border-bottom: 1px dashed var(--divider);
font-size: 14.5px;
color: var(--text-muted);
}
.price-features li:last-child { border-bottom: none; }
.price-features strong { color: var(--text); font-weight: 700; }
.price-note {
font-size: 12px;
color: var(--text-dim);
margin: 14px 0 0;
}
/* Grid 4 colonne per i piani */
.price-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 32px;
align-items: stretch;
}
@media (max-width: 1100px) {
.price-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
.price-grid { grid-template-columns: 1fr; }
}
.price-card-sm {
margin: 0;
max-width: none;
padding: 32px 22px 24px;
display: flex;
flex-direction: column;
box-shadow: none;
border: 1px solid var(--border);
}
.price-card-sm .price-amount { font-size: 44px; }
.price-card-sm .price-currency { font-size: 20px; }
.price-card-sm .price-once { font-size: 13px; }
.price-card-sm .price-features { margin-bottom: 22px; flex: 1; }
.price-card-sm .price-features li { padding: 7px 0; font-size: 13.5px; }
.price-card-featured {
border-color: var(--accent);
box-shadow: 0 18px 48px rgba(29, 185, 84, 0.18);
}
.price-badge-gold {
background: linear-gradient(135deg, #f8c44a, #f4a51c);
color: #0a0a0a;
}
.price-shared {
max-width: 720px;
margin: 0 auto;
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-list {
max-width: 760px;
margin: 0 auto;
}
.faq-item {
border: 1px solid var(--border);
background: var(--bg-card);
border-radius: 14px;
margin-bottom: 10px;
overflow: hidden;
}
.faq-item summary {
list-style: none;
cursor: pointer;
padding: 18px 22px;
font-weight: 700;
font-size: 15.5px;
position: relative;
padding-right: 50px;
transition: background 0.18s ease;
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::after {
content: "+";
position: absolute;
right: 22px;
top: 50%;
transform: translateY(-50%);
font-size: 22px;
color: var(--accent);
font-weight: 400;
transition: transform 0.25s ease;
}
.faq-item[open] summary::after { content: "−"; }
.faq-item summary:hover { background: var(--bg-card-2); }
.faq-item p {
margin: 0;
padding: 0 22px 18px;
color: var(--text-muted);
font-size: 14.5px;
line-height: 1.6;
}
.faq-item code {
background: var(--bg-card-2);
padding: 2px 7px;
border-radius: 5px;
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--accent);
}
/* ============================================================
FINAL CTA
============================================================ */
.cta-final { padding: 60px 0 90px; }
.cta-box {
text-align: center;
background:
radial-gradient(ellipse at center, rgba(29, 185, 84, 0.2), transparent 70%),
linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card-2) 100%);
border: 1px solid var(--border);
border-radius: 22px;
padding: 56px 28px;
max-width: 880px;
margin: 0 auto;
}
.cta-box h2 { font-size: clamp(24px, 3.2vw, 34px); font-weight: 900; margin: 0 0 8px; letter-spacing: -0.02em; }
.cta-box p { color: var(--text-muted); margin: 0 0 22px; font-size: 16px; }
/* ============================================================
FOOTER
============================================================ */
.footer {
background: var(--bg-2);
border-top: 1px solid var(--border);
padding: 36px 0 30px;
font-size: 13.5px;
color: var(--text-muted);
}
.footer-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 18px;
margin-bottom: 14px;
}
.footer-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: var(--text);
}
.footer-by { color: var(--text-dim); font-weight: 400; }
.footer-links { display: flex; gap: 22px; }
.footer-links a:hover { color: var(--text); }
.footer-copy {
font-size: 12px;
color: var(--text-dim);
border-top: 1px solid var(--divider);
padding-top: 16px;
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 920px) {
.hero { padding: 60px 0 70px; }
.hero-grid { grid-template-columns: 1fr; gap: 40px; }
.hero-art { display: none; } /* su mobile nascondiamo il mockup */
.nav-links { display: none; }
.section { padding: 70px 0; }
.how-grid { grid-template-columns: 1fr; }
.footer-row { flex-direction: column; align-items: flex-start; }
}
@media (max-width: 480px) {
.container { padding: 0 18px; }
.hero-cta .btn { width: 100%; }
.price-amount { font-size: 52px; }
}
/* ============================================================
ANIMAZIONI SCROLL
============================================================ */
.nav.scrolled { box-shadow: 0 1px 0 var(--border), 0 8px 24px rgba(0,0,0,0.4); }
.feat, .how-step, .price-card, .faq-item {
opacity: 0;
transform: translateY(16px);
transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.feat.in-view, .how-step.in-view, .price-card.in-view, .faq-item.in-view {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.feat, .how-step, .price-card, .faq-item {
opacity: 1; transform: none; transition: none;
}
.mockup { transform: none !important; }
.mc-progress-fill { animation: none !important; width: 64% !important; }
html { scroll-behavior: auto; }
}
/* ============================================================
USE POLICY BOX
============================================================ */
.use-policy {
max-width: 720px;
margin: 50px auto 0;
padding: 26px 28px;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
border-radius: 14px;
}
.use-policy h3 {
margin: 0 0 10px;
font-size: 16px;
font-weight: 800;
color: var(--text);
}
.use-policy p {
margin: 0 0 12px;
font-size: 13.5px;
color: var(--text-muted);
line-height: 1.6;
}
.use-points {
list-style: none;
padding: 0;
margin: 0 0 16px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 18px;
}
.use-points li {
font-size: 13px;
color: var(--text-muted);
}
.use-fineprint {
margin: 8px 0 0 !important;
font-size: 12.5px !important;
color: var(--text-dim) !important;
}
.use-fineprint a { color: var(--accent); }
@media (max-width: 640px) {
.use-points { grid-template-columns: 1fr; }
}
.price-consent {
font-size: 11.5px !important;
color: var(--text-dim) !important;
margin: 12px 0 0 !important;
line-height: 1.5 !important;
}
.price-consent a { color: var(--text-muted); text-decoration: underline; }
.price-consent a:hover { color: var(--accent); }
/* ============================================================
"Hai gia acquistato? Scarica" — link discreti
============================================================ */
.hero-customer {
margin: 18px 0 0;
font-size: 13.5px;
color: var(--text-dim);
}
.hero-customer a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.hero-customer a:hover { text-decoration: underline; }
.price-already {
margin: 10px 0 0 !important;
font-size: 13px !important;
color: var(--text-muted) !important;
}
.price-already a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.price-already a:hover { text-decoration: underline; }
+303
View File
@@ -0,0 +1,303 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scarica MusicTools</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<link rel="stylesheet" href="css/style.css" />
<style>
.dl-page {
padding: 60px 24px 100px;
max-width: 900px;
margin: 0 auto;
}
.dl-head {
text-align: center;
margin-bottom: 48px;
}
.dl-head h1 {
font-size: clamp(28px, 4vw, 42px);
font-weight: 900;
letter-spacing: -0.02em;
margin: 12px 0 10px;
}
.dl-head p {
color: var(--text-muted);
font-size: 17px;
margin: 0;
}
.dl-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 40px;
}
@media (max-width: 700px) {
.dl-grid { grid-template-columns: 1fr; }
}
.dl-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 32px 28px;
text-align: center;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.25s ease;
}
.dl-card:hover {
transform: translateY(-3px);
border-color: #383838;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
}
.dl-os { font-size: 64px; line-height: 1; margin-bottom: 12px; }
.dl-card h2 { margin: 0 0 6px; font-size: 22px; font-weight: 800; }
.dl-card .meta { color: var(--text-dim); font-size: 13px; margin: 0 0 24px; }
.dl-form {
max-width: 460px;
margin: 0 auto 32px;
padding: 28px 30px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
}
.dl-form h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 800;
}
.dl-form p { margin: 0 0 18px; color: var(--text-muted); font-size: 14px; }
.dl-form .field { margin-bottom: 12px; }
.dl-form .field-label { font-size: 12px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 6px; }
.dl-form .input { width: 100%; padding: 12px 16px; border-radius: 999px; background: #0d0d0d; border: 1px solid var(--border); color: var(--text); font-size: 14px; font-family: inherit; box-sizing: border-box; }
.dl-form .input:focus { outline: none; border-color: var(--accent); }
.dl-form .submit { width: 100%; margin-top: 12px; }
.dl-steps {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 28px 32px;
margin-bottom: 18px;
}
.dl-steps h2 { font-size: 18px; font-weight: 800; margin: 0 0 14px; }
.dl-steps ol { margin: 0; padding-left: 22px; color: var(--text-muted); font-size: 14.5px; line-height: 1.7; }
.dl-steps li { margin-bottom: 4px; }
.dl-steps strong { color: var(--text); }
.dl-steps code { background: var(--bg-card-2); padding: 2px 7px; border-radius: 5px; font-size: 13px; font-family: ui-monospace, monospace; color: var(--accent); }
.dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; }
.dl-support a { color: var(--accent); }
.dl-code {
display: block;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
margin: 8px 0 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: var(--accent);
user-select: all;
word-break: break-all;
}
.dl-sub {
margin: 8px 0 0;
padding-left: 20px;
color: var(--text-muted);
font-size: 14.5px;
line-height: 1.6;
}
.dl-sub li { margin-bottom: 4px; }
.dl-sub strong { color: var(--text); }
.dl-sub code { background: var(--bg-card-2); padding: 1px 6px; border-radius: 4px; font-size: 12.5px; }
.dl-status {
max-width: 460px;
margin: -16px auto 24px;
padding: 12px 18px;
text-align: center;
background: rgba(29, 185, 84, 0.08);
border: 1px solid rgba(29, 185, 84, 0.25);
color: var(--accent);
border-radius: 12px;
font-size: 14px;
font-weight: 600;
}
.dl-status.err {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
}
</style>
</head>
<body>
<header class="nav">
<div class="container nav-row">
<a href="/" class="brand">
<span class="brand-logo">♫</span>
<span class="brand-name">MusicTools</span>
</a>
<nav class="nav-links">
<a href="/">Home</a>
<a href="/#features">Funzioni</a>
<a href="/#faq">FAQ</a>
</nav>
<a href="mailto:info@djluza.com" class="btn btn-ghost btn-sm">Supporto</a>
</div>
</header>
<main class="dl-page">
<div class="dl-head">
<span class="eyebrow">SCARICA</span>
<h1>Scarica MusicTools</h1>
<p>Scegli il sistema operativo e procedi con l'installazione</p>
</div>
<!-- Form mostrato quando NON arrivi dall'email (no key/email in URL) -->
<div class="dl-form" id="dlForm">
<h2>Inserisci le tue credenziali</h2>
<p>Hai gia' acquistato? Inserisci l'email e la chiave per scaricare.</p>
<div class="field">
<label class="field-label" for="dlEmail">Email dell'acquisto</label>
<input type="email" id="dlEmail" class="input" autocomplete="email" />
</div>
<div class="field">
<label class="field-label" for="dlKey">Chiave di licenza</label>
<input type="text" id="dlKey" class="input" autocomplete="off" placeholder="XXXX-XXXX-XXXX-XXXX" />
</div>
<button class="btn btn-primary btn-lg submit" id="dlSubmitBtn">Mostra i link di download</button>
</div>
<!-- Cards di download (popolate dopo inserimento credenziali o da URL) -->
<div class="dl-grid" id="dlGrid" hidden>
<article class="dl-card">
<div class="dl-os">🍎</div>
<h2>macOS</h2>
<p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p>
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlMacBtn" data-platform="macos">
Scarica per Mac
</a>
</article>
<article class="dl-card">
<div class="dl-os">🪟</div>
<h2>Windows</h2>
<p class="meta">Windows 10 e 11 · 64-bit</p>
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlWinBtn" data-platform="windows">
Scarica per Windows
</a>
</article>
</div>
<p id="dlStatus" class="dl-status" hidden></p>
<div class="dl-steps">
<h2>Installazione macOS</h2>
<ol>
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
<li>
Autorizza l'app (una sola volta, perche' non e' firmata con Developer ID Apple).<br>
<strong>Modo piu' semplice:</strong> apri il <strong>Terminale</strong> (cmd+spazio &rarr; "Terminale"), incolla questa riga e premi Invio:
<p class="dl-code">xattr -dr com.apple.quarantine /Applications/MusicTools.app</p>
<strong>In alternativa</strong> (senza Terminale):
<ul class="dl-sub">
<li><strong>Tasto destro</strong> su <code>Fix Gatekeeper.command</code> nel DMG &rarr; <strong>Apri</strong> &rarr; <strong>Apri</strong>. Se compare il blocco "non e' in grado di verificare", clicca <strong>Annulla</strong> e procedi col passo successivo.</li>
<li>Apri <strong>Impostazioni di Sistema</strong> &rarr; <strong>Privacy e sicurezza</strong>. In fondo vedi "Fix Gatekeeper.command e' stato bloccato": clicca <strong>Apri comunque</strong>.</li>
</ul>
</li>
<li>Apri <strong>MusicTools</strong> dalle Applicazioni, inserisci email e chiave per attivare</li>
</ol>
</div>
<div class="dl-steps">
<h2>Installazione Windows</h2>
<ol>
<li>Estrai il file <code>.zip</code> in una cartella di tua scelta</li>
<li>Doppio click su <strong>MusicTools.exe</strong></li>
<li>Se Windows mostra "Windows ha protetto il PC": click su <strong>Ulteriori informazioni</strong> → <strong>Esegui comunque</strong></li>
<li>Inserisci email e chiave per attivare</li>
</ol>
</div>
<div class="dl-support">
Problemi con l'installazione o non hai ricevuto l'email con la chiave?<br>
Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> — risposta entro 24 ore.
</div>
</main>
<script>
function getParam(name) {
const url = new URL(window.location.href);
return url.searchParams.get(name) || "";
}
// Stato corrente — popolato da URL o dal form submit.
const dl = { email: "", key: "" };
function setStatus(text, isError) {
const el = document.getElementById("dlStatus");
if (!text) { el.hidden = true; el.textContent = ""; return; }
el.textContent = text;
el.classList.toggle("err", !!isError);
el.hidden = false;
}
function showDownloadCards(email, key) {
dl.email = email;
dl.key = key;
document.getElementById("dlGrid").hidden = false;
document.getElementById("dlForm").hidden = true;
}
// Click sui bottoni: naviga sulla stessa tab verso /api/download/:platform.
// Il server fa 302 con Content-Disposition: attachment -> il browser
// scarica il file e la pagina /download resta visibile.
document.querySelectorAll(".dl-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
if (!dl.email || !dl.key) {
setStatus("Inserisci email e chiave prima di scaricare.", true);
return;
}
const platform = btn.dataset.platform;
const q = "key=" + encodeURIComponent(dl.key) +
"&email=" + encodeURIComponent(dl.email);
setStatus("Avvio del download in corso… il file si apre fra qualche secondo.");
window.location.href = "/api/download/" + platform + "?" + q;
});
});
// Se arrivi dall'email con ?key=&email= mostra subito i bottoni.
const urlKey = getParam("key");
const urlEmail = getParam("email");
if (urlKey && urlEmail) {
showDownloadCards(urlEmail, urlKey);
}
// Form submit
document.getElementById("dlSubmitBtn").addEventListener("click", () => {
const email = document.getElementById("dlEmail").value.trim();
const key = document.getElementById("dlKey").value.trim();
if (!email || !key) {
setStatus("Inserisci email e chiave di licenza.", true);
return;
}
setStatus("");
showDownloadCards(email, key);
});
// Enter dentro i campi
["dlEmail", "dlKey"].forEach(id => {
document.getElementById(id).addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("dlSubmitBtn").click();
});
});
</script>
</body>
</html>
+485
View File
@@ -0,0 +1,485 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MusicTools — Scarica, modifica e registra la tua musica</title>
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag. Piani da 2,99 €/mese." />
<meta property="og:title" content="MusicTools — La toolbox della tua musica" />
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows. Piani da 2,99 €/mese o 49,90 €/anno una tantum." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://musictools.djluza.com" />
<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>
<!-- ============== NAV ============== -->
<header class="nav">
<div class="container nav-row">
<a href="#top" class="brand">
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
<span class="brand-name">MusicTools</span>
</a>
<nav class="nav-links">
<a href="#features">Funzioni</a>
<a href="#how">Come funziona</a>
<a href="#pricing">Piani</a>
<a href="#faq">FAQ</a>
<a href="/download">Download</a>
</nav>
<a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</a>
</div>
</header>
<!-- ============== HERO ============== -->
<section class="hero" id="top">
<div class="container hero-grid">
<div class="hero-text">
<span class="eyebrow">Per Mac e Windows · Da 2,99 €/mese</span>
<h1>La tua musica, ovunque la trovi.<br/><span class="accent">In un'unica app.</span></h1>
<p class="lead">
Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K.
Registra in alta qualità l'audio che esce dal tuo computer.
Modifica i metadati e le copertine. Tutto offline, senza account.
</p>
<div class="hero-cta">
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
<a href="#features" class="btn btn-ghost btn-lg">Scopri di più</a>
</div>
<ul class="hero-bullets">
<li>✓ Mensili da 2,99 € o annuale 49,90 € una tantum</li>
<li>✓ Aggiornamenti gratuiti inclusi</li>
<li>✓ Funziona offline · Su 3 dispositivi</li>
</ul>
<p class="hero-customer">
Hai già acquistato? <a href="/download">Scarica l'app →</a>
</p>
</div>
<div class="hero-art" aria-hidden="true">
<!-- Mockup dell'app -->
<div class="mockup">
<div class="mockup-bar">
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
<span class="mockup-title">MusicTools</span>
</div>
<div class="mockup-body">
<aside class="mockup-side">
<div class="ms-brand"><span>♫</span> MusicTools</div>
<div class="ms-nav">
<div class="ms-item active"><span>⬇</span> Scarica</div>
<div class="ms-item"><span>🎬</span> Video</div>
<div class="ms-item"><span>⚡</span> Upgrade</div>
<div class="ms-item"><span>🏷</span> Metadati</div>
<div class="ms-item"><span>●</span> Registra</div>
<div class="ms-item"><span>⚙</span> Impostazioni</div>
<div class="ms-item"><span>📖</span> Guida</div>
</div>
</aside>
<main class="mockup-main">
<div class="mockup-hero">
<div class="mh-eyebrow">DOWNLOAD</div>
<div class="mh-title">Scarica la tua musica</div>
<div class="mh-sub">Spotify · YouTube · SoundCloud</div>
<div class="mh-deco">♫</div>
</div>
<div class="mockup-card">
<div class="mc-label">URL o titolo brano</div>
<div class="mc-input">https://open.spotify.com/playlist/…</div>
</div>
<div class="mockup-card mockup-actions">
<div class="mc-btn primary">▶ Avvia Download</div>
<div class="mc-progress">
<div class="mc-progress-fill" style="width: 64%"></div>
</div>
</div>
</main>
</div>
</div>
</div>
</div>
</section>
<!-- ============== FEATURES ============== -->
<section class="section features" id="features">
<div class="container">
<span class="eyebrow center">FUNZIONI</span>
<h2 class="section-title">Sette strumenti, un'unica app</h2>
<p class="section-lead">Tutto quello che ti serve per gestire la tua musica, senza saltare tra siti web e tool diversi.</p>
<div class="feat-grid">
<article class="feat green">
<div class="feat-icon">⬇</div>
<h3>Scarica</h3>
<p>Brani singoli, playlist Spotify, link YouTube, tracklist su file. Detect automatico, MP3 320K con copertina.</p>
</article>
<article class="feat pink">
<div class="feat-icon">🎬</div>
<h3>Video</h3>
<p>YouTube, TikTok, Instagram, Facebook. Scegli la qualità (fino a 4K) o estrai solo l'audio in MP3.</p>
</article>
<article class="feat blue">
<div class="feat-icon">⚡</div>
<h3>Upgrade</h3>
<p>Scansiona una cartella, trova i file a bitrate basso e li riscarica in alta qualità. Mantiene metadati e cover.</p>
</article>
<article class="feat amber">
<div class="feat-icon">🏷</div>
<h3>Metadati</h3>
<p>Editor completo per MP3, M4A, FLAC, WAV. Cambia copertina, artista, album, BPM, key. Anche `kMDItemWhereFroms` su macOS.</p>
</article>
<article class="feat red">
<div class="feat-icon">●</div>
<h3>Registra</h3>
<p>Cattura l'audio di qualunque app (Spotify, Apple Music, browser) via BlackHole o scheda interna. Output MP3 320K.</p>
</article>
<article class="feat purple">
<div class="feat-icon">📖</div>
<h3>Guida integrata</h3>
<p>Manuale completo dentro l'app: setup, troubleshooting, esempi. Niente schede del browser da cercare.</p>
</article>
</div>
</div>
</section>
<!-- ============== HOW ============== -->
<section class="section how" id="how">
<div class="container">
<span class="eyebrow center">COME FUNZIONA</span>
<h2 class="section-title">Tre passi, due minuti</h2>
<div class="how-grid">
<div class="how-step">
<div class="how-num">1</div>
<h3>Compra la licenza</h3>
<p>Checkout sicuro tramite Lemon Squeezy. Ricevi la chiave via email entro pochi secondi.</p>
</div>
<div class="how-step">
<div class="how-num">2</div>
<h3>Scarica l'app</h3>
<p>Mac o Windows, link diretto nella stessa email. Installazione standard, niente dipendenze.</p>
</div>
<div class="how-step">
<div class="how-num">3</div>
<h3>Attiva e parti</h3>
<p>Inserisci email e chiave all'avvio. L'app si attiva e resta valida a vita su questo dispositivo.</p>
</div>
</div>
</div>
</section>
<!-- ============== PRICING ============== -->
<section class="section pricing" id="pricing">
<div class="container">
<span class="eyebrow center">PIANI</span>
<h2 class="section-title">Scegli il piano giusto per te</h2>
<p class="section-lead">Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.</p>
<div class="price-grid">
<!-- BASIC -->
<div class="price-card price-card-sm">
<div class="price-name">Basic</div>
<div class="price-row">
<span class="price-amount">2,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Download brani</strong> da Spotify e YouTube</li>
<li>✓ <strong>Fino a 10 al giorno</strong></li>
<li>✓ MP3 fino a 320kbps</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
<li>✗ Download video</li>
<li>✗ Registrazione, metadati, upgrade libreria</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1"
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
Inizia con Basic
</a>
</div>
<!-- PRO -->
<div class="price-card price-card-sm">
<div class="price-badge">Più scelto</div>
<div class="price-name">Pro</div>
<div class="price-row">
<span class="price-amount">5,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutto di Basic</strong></li>
<li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
<li>✓ <strong>Fino a 30 al giorno</strong></li>
<li>✓ Registrazione audio (BlackHole / loopback)</li>
<li>✓ Editor metadati + copertine</li>
<li>✓ Upgrade qualità libreria</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/89cb5ab5-9d39-4828-8c44-b67a651aeb46?embed=1"
class="btn btn-primary btn-lg btn-block lemonsqueezy-button">
Scegli Pro
</a>
</div>
<!-- PREMIUM -->
<div class="price-card price-card-sm">
<div class="price-name">Premium</div>
<div class="price-row">
<span class="price-amount">9,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutto di Pro</strong></li>
<li>✓ <strong>Fino a 150 al giorno</strong></li>
<li>✓ Priorità nel supporto via email</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/5e027bbf-30ba-4ef3-bb23-5e7ea4aa7e7e?embed=1"
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
Passa a Premium
</a>
</div>
<!-- ANNUAL ONE-TIME -->
<div class="price-card price-card-sm price-card-featured">
<div class="price-badge price-badge-gold">Miglior valore</div>
<div class="price-name">Annual</div>
<div class="price-row">
<span class="price-amount">49,90</span>
<span class="price-currency">€</span>
<span class="price-once">/anno</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutte le funzioni sbloccate</strong></li>
<li>✓ <strong>Nessun limite giornaliero</strong></li>
<li>✓ Valido <strong>12 mesi</strong> dall'acquisto</li>
<li>✓ Una tantum, niente rinnovo automatico</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/85e91098-a93c-4008-a155-954b3456a299?embed=1"
class="btn btn-primary btn-lg btn-block lemonsqueezy-button"
id="buyBtn">
Acquista — 49,90 €
</a>
</div>
</div>
<div class="price-shared">
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy</p>
<p class="price-consent">
Procedendo con l'acquisto dichiari di aver letto e di accettare i
<a href="/legal/terms.html">Termini di servizio</a>,
l'<a href="/legal/privacy.html">Informativa privacy</a> e la
<a href="/legal/refund.html">Politica rimborsi</a>.
</p>
<p class="price-already">
Hai già una licenza? <a href="/download">Scarica l'app →</a>
</p>
</div>
<div class="spotify-note">
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
<p>
MusicTools funziona <strong>al 100% subito</strong> con YouTube,
SoundCloud, TikTok, Instagram, Facebook e i singoli brani Spotify
(anche con account Spotify gratuito).<br>
Per scaricare <strong>playlist e album interi da Spotify</strong> servono
le tue <strong>chiavi API Spotify</strong>, che oggi Spotify rilascia solo
a chi ha un <strong>account Premium</strong> attivo (le crei tu dal
Developer Dashboard, in 2 minuti, con la guida integrata nell'app).
</p>
<div class="spotify-note-grid">
<div class="sn-col sn-with">
<div class="sn-title">✓ Con Spotify Premium + chiavi API</div>
<ul>
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
<li>Tutte le altre fonti restano sempre attive</li>
</ul>
</div>
<div class="sn-col sn-without">
<div class="sn-title">Senza Premium (modalita' base)</div>
<ul>
<li>Da Spotify scarichi <strong>un brano alla volta</strong> incollando il link</li>
<li>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
</ul>
</div>
</div>
<p class="spotify-note-fine">
MusicTools non e' affiliato a Spotify. Le chiavi API restano sul tuo
dispositivo, non passano per i nostri server.
</p>
</div>
<div class="use-policy">
<h3>Uso responsabile</h3>
<p>
MusicTools e' uno strumento tecnico per l'automazione di download e registrazione audio,
paragonabile a un browser o a un registratore digitale.
<strong>Sei tu a scegliere quali contenuti acquisire</strong> e sei l'unico responsabile
del rispetto del diritto d'autore e dei Termini di servizio delle piattaforme di origine.
</p>
<ul class="use-points">
<li>✓ Backup dei brani che hai gia acquistato</li>
<li>✓ Contenuti tuoi o rilasciati con licenza libera</li>
<li>✗ Distribuzione, rivendita o uso commerciale dei contenuti scaricati</li>
<li>✗ Aggiramento di misure di protezione (DRM)</li>
</ul>
<p class="use-fineprint">
Leggi i <a href="/legal/terms.html">Termini di servizio completi</a> prima dell'acquisto.
</p>
</div>
</div>
</section>
<!-- ============== FAQ ============== -->
<section class="section faq" id="faq">
<div class="container">
<span class="eyebrow center">DOMANDE FREQUENTI</span>
<h2 class="section-title">Tutto quello che devi sapere</h2>
<div class="faq-list">
<details class="faq-item">
<summary>Su quali sistemi operativi funziona?</summary>
<p>macOS 11 (Big Sur) o successivi, e Windows 10/11. L'app non gira su Linux per ora.</p>
</details>
<details class="faq-item">
<summary>Su quanti dispositivi posso installarla?</summary>
<p>Tutti i piani coprono fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
</details>
<details class="faq-item">
<summary>Come funziona il limite giornaliero?</summary>
<p>Sui piani mensili (Basic, Pro, Premium) c'è un limite di download al giorno (10, 30 o 150 a seconda del piano). Il contatore si resetta a <strong>mezzanotte ora di Roma</strong>. Sull'annuale (49,90 €) non c'è nessun limite per 12 mesi.</p>
</details>
<details class="faq-item">
<summary>Posso passare a un piano superiore?</summary>
<p>Sì, in qualsiasi momento. Su Lemon Squeezy puoi cambiare piano dalla tua dashboard; la licenza si aggiorna in automatico al successivo controllo dell'app.</p>
</details>
<details class="faq-item">
<summary>Cosa succede quando scade l'abbonamento annuale?</summary>
<p>Il piano Annual è una-tantum: vale 12 mesi dall'acquisto, non si rinnova automaticamente. Allo scadere ricomprilo (o scegli un mensile) e tutto torna a funzionare.</p>
</details>
<details class="faq-item">
<summary>Come funzionano gli aggiornamenti?</summary>
<p>Sono <strong>inclusi in tutti i piani</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
</details>
<details class="faq-item">
<summary>Mi serve un abbonamento Spotify Premium?</summary>
<p>
Solo se vuoi scaricare <strong>playlist e album interi</strong> da Spotify:
in quel caso servono le chiavi API Spotify, che oggi Spotify rilascia
solo agli account Premium. Con un account Spotify gratuito (o senza
account Spotify) <strong>puoi comunque scaricare</strong> da YouTube,
SoundCloud, TikTok, IG, FB e i singoli brani Spotify incollando il link.
</p>
<p>YouTube non richiede alcun account.</p>
</details>
<details class="faq-item">
<summary>Cosa posso fare senza configurare le chiavi API Spotify?</summary>
<p>
<strong>Da YouTube, SoundCloud, TikTok, Instagram, Facebook</strong>:
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
</p>
<p>
<strong>Da Spotify</strong>: puoi scaricare <strong>un brano alla volta</strong>
incollando il link al singolo brano (l'app estrae artista e titolo
dai metadati pubblici di Spotify e cerca su YouTube). Per <strong>album
e playlist</strong> servono le chiavi API, che Spotify rilascia solo
agli account Premium.
</p>
</details>
<details class="faq-item">
<summary>Posso registrare l'audio di Spotify mentre suona?</summary>
<p>Sì, con BlackHole (macOS) o la scheda audio virtuale di Windows. La tab Registra ti guida nel setup passo-passo.</p>
</details>
<details class="faq-item">
<summary>L'app si installa senza problemi su Mac?</summary>
<p>Al primo avvio macOS chiede una conferma di sicurezza. Tasto destro sull'app → Apri → Apri. Lo fai una volta sola, poi parte normalmente. Nel DMG trovi anche uno script <code>Fix Gatekeeper.command</code> che lo fa per te.</p>
</details>
<details class="faq-item">
<summary>E su Windows?</summary>
<p>Al primo avvio Windows SmartScreen mostra "Windows protected your PC". Clicca su <strong>More info</strong> → <strong>Run anyway</strong>. È una procedura standard per app non firmate con cert EV.</p>
</details>
<details class="faq-item">
<summary>Posso chiedere il rimborso se non mi piace?</summary>
<p>Sì, hai <strong>14 giorni</strong> per chiederlo senza fornire motivazioni. Scrivi a info@djluza.com.</p>
</details>
<details class="faq-item">
<summary>Posso usare i brani scaricati commercialmente?</summary>
<p>No. La licenza copre <strong>esclusivamente l'uso personale</strong>. I contenuti scaricati restano soggetti al copyright dei rispettivi proprietari: sei tu responsabile di rispettare i loro termini d'uso e di non distribuirli, rivenderli o esibirli pubblicamente. Vedi <a href="/legal/terms.html#s5">§5 dei Termini</a>.</p>
</details>
<details class="faq-item">
<summary>MusicTools è collegato a Spotify, YouTube o altre piattaforme?</summary>
<p>No. MusicTools <strong>non è affiliato, sponsorizzato o autorizzato</strong> da nessuna di queste società. È uno strumento tecnico indipendente che usa librerie open-source per interagire con risorse pubbliche. I marchi citati sono dei rispettivi proprietari.</p>
</details>
<details class="faq-item">
<summary>Sono titolare di un copyright e voglio segnalare un uso illegittimo</summary>
<p>Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> con prova della titolarità, identificazione del materiale e dichiarazione di buona fede. La procedura completa è descritta al <a href="/legal/terms.html#s8">§8 dei Termini</a>.</p>
</details>
<details class="faq-item">
<summary>Quali metodi di pagamento sono accettati?</summary>
<p>Tramite Lemon Squeezy: carta di credito/debito, PayPal, Apple Pay, Google Pay. Riceverai una fattura per il pagamento.</p>
</details>
</div>
</div>
</section>
<!-- ============== FINAL CTA ============== -->
<section class="section cta-final">
<div class="container cta-box">
<h2>Pronto a semplificarti la vita?</h2>
<p>Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.</p>
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
</div>
</section>
<!-- ============== FOOTER ============== -->
<footer class="footer">
<div class="container footer-row">
<div class="footer-brand">
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
<span>MusicTools</span>
<span class="footer-by">by LuZa</span>
</div>
<nav class="footer-links">
<a href="/download">Download</a>
<a href="mailto:info@djluza.com">Supporto</a>
<a href="/legal/terms.html">Termini</a>
<a href="/legal/privacy.html">Privacy</a>
<a href="/legal/refund.html">Rimborsi</a>
</nav>
</div>
<div class="container footer-copy">
© <span id="year">2026</span> MusicTools — Luciano Zappacosta · Tutti i diritti riservati
</div>
</footer>
<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
<script src="js/main.js" defer></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
/* ======================================================
MusicTools landing — JS minimale
====================================================== */
// Anno corrente in footer
(function setYear() {
const el = document.getElementById("year");
if (el) el.textContent = String(new Date().getFullYear());
})();
// Lemon Squeezy overlay: lemon.js intercetta automaticamente i link con
// data-lemonsqueezy-url e apre l'overlay di checkout. Niente codice nostro
// necessario qui — basta che lemon.js sia caricato (vedi <script> in index.html).
// Header che diventa piu' opaco mentre scorri (effetto subtle)
const nav = document.querySelector(".nav");
function onScroll() {
if (!nav) return;
if (window.scrollY > 24) nav.classList.add("scrolled");
else nav.classList.remove("scrolled");
}
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
// Reveal on scroll: aggiunge la classe .in-view alle sezioni
// quando entrano in viewport. Niente librerie esterne.
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("in-view");
io.unobserve(entry.target);
}
});
}, { threshold: 0.12 });
document.querySelectorAll(".feat, .how-step, .price-card, .faq-item").forEach((el) => {
io.observe(el);
});
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Privacy · MusicTools</title>
<link rel="stylesheet" href="../css/style.css" />
<style>
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
.legal-doc h2 { font-size: 20px; margin-top: 32px; font-weight: 800; }
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
.legal-doc a { color: var(--accent); }
</style>
</head>
<body>
<header class="nav"><div class="container nav-row">
<a href="/" class="brand"><span class="brand-logo">♫</span><span class="brand-name">MusicTools</span></a>
</div></header>
<main class="legal-doc">
<h1>Informativa Privacy</h1>
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
<h2>1. Titolare del trattamento</h2>
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
<h2>2. Dati trattati</h2>
<ul>
<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>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>
</ul>
<h2>3. Finalita' e base giuridica</h2>
<ul>
<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>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>
<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. Il contatore download giornaliero viene mantenuto per 90 giorni e poi cancellato.</p>
<h2>5. Terze parti</h2>
<ul>
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento, gestione abbonamenti.</li>
<li><strong>Resend Inc.</strong> — invio email transazionali (consegna chiave di licenza, comunicazioni di servizio).</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>
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
<h2>6. Diritti dell'interessato</h2>
<p>L'utente ha diritto di accedere, rettificare, cancellare i propri dati e di ottenere portabilita' o limitazione del trattamento. Per esercitare questi diritti scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
<h2>7. Cookie</h2>
<p>Questo sito non usa cookie di tracciamento. Lemon Squeezy puo' impostare i propri cookie funzionali durante il checkout secondo la sua informativa.</p>
<h2>8. Reclami</h2>
<p>In caso di violazioni puoi rivolgerti al Garante per la protezione dei dati personali (<a href="https://www.garanteprivacy.it">garanteprivacy.it</a>).</p>
</main>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Politica rimborsi · MusicTools</title>
<link rel="stylesheet" href="../css/style.css" />
<style>
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
.legal-doc h2 { font-size: 20px; margin-top: 32px; font-weight: 800; }
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
.legal-doc a { color: var(--accent); }
</style>
</head>
<body>
<header class="nav"><div class="container nav-row">
<a href="/" class="brand"><span class="brand-logo">♫</span><span class="brand-name">MusicTools</span></a>
</div></header>
<main class="legal-doc">
<h1>Politica rimborsi</h1>
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
<h2>Garanzia 14 giorni sul primo acquisto</h2>
<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>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>
<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>La tua chiave di licenza viene revocata. L'app smette di funzionare al successivo controllo online.</li>
</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>
<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>
<p>Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>: siamo persone vere, rispondiamo entro un giorno lavorativo.</p>
</main>
</body>
</html>
+238
View File
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Termini di servizio · MusicTools</title>
<link rel="stylesheet" href="../css/style.css" />
<style>
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
.legal-doc h2 { font-size: 20px; margin-top: 36px; font-weight: 800; }
.legal-doc h3 { font-size: 16px; margin-top: 22px; font-weight: 700; color: var(--text); }
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
.legal-doc strong { color: var(--text); }
.legal-doc a { color: var(--accent); }
.legal-doc .toc {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; padding: 18px 22px; margin-bottom: 32px;
}
.legal-doc .toc ol { margin: 0; padding-left: 22px; }
.legal-doc .toc li { margin: 4px 0; }
.legal-doc .toc a { color: var(--text-muted); font-size: 14px; }
.legal-doc .toc a:hover { color: var(--accent); }
.legal-doc .warn-box {
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.4);
border-left: 4px solid var(--red);
padding: 16px 20px; border-radius: 10px; margin: 22px 0;
}
.legal-doc .warn-box p { margin: 0; color: var(--text); }
</style>
</head>
<body>
<header class="nav"><div class="container nav-row">
<a href="/" class="brand"><span class="brand-logo">♫</span><span class="brand-name">MusicTools</span></a>
</div></header>
<main class="legal-doc">
<h1>Termini di servizio e Licenza d'uso</h1>
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
<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>
</div>
<div class="toc">
<strong>Indice</strong>
<ol>
<li><a href="#s1">Definizioni</a></li>
<li><a href="#s2">Accettazione dei termini</a></li>
<li><a href="#s3">Licenza d'uso (EULA)</a></li>
<li><a href="#s4">Restrizioni d'uso</a></li>
<li><a href="#s5">Acceptable Use Policy</a></li>
<li><a href="#s6">Copyright e responsabilita dell'utente</a></li>
<li><a href="#s7">Marchi e affiliazioni con terze parti</a></li>
<li><a href="#s8">Procedura DMCA / segnalazioni</a></li>
<li><a href="#s9">Esclusione di garanzie</a></li>
<li><a href="#s10">Limitazione di responsabilita</a></li>
<li><a href="#s11">Manleva (indemnification)</a></li>
<li><a href="#s12">Pagamenti e fatturazione</a></li>
<li><a href="#s13">Diritto di recesso e rimborsi</a></li>
<li><a href="#s14">Aggiornamenti e supporto</a></li>
<li><a href="#s15">Risoluzione</a></li>
<li><a href="#s16">Modifiche ai termini</a></li>
<li><a href="#s17">Legge applicabile e foro competente</a></li>
<li><a href="#s18">Contatti</a></li>
</ol>
</div>
<h2 id="s1">1. Definizioni</h2>
<p>Nel presente documento:</p>
<ul>
<li><strong>"Editore"</strong> — Luciano Zappacosta, in qualita' di sviluppatore e distributore del software, contattabile a <a href="mailto:info@djluza.com">info@djluza.com</a>.</li>
<li><strong>"Software" o "MusicTools"</strong> — l'applicazione desktop denominata MusicTools, comprensiva di codice eseguibile, asset, documentazione e aggiornamenti.</li>
<li><strong>"Utente"</strong> — la persona fisica o giuridica che acquista, installa o utilizza il Software.</li>
<li><strong>"Licenza"</strong> — il diritto d'uso personale concesso all'Utente dietro pagamento, come descritto al §3.</li>
<li><strong>"Contenuti"</strong> — file audio, video, immagini, metadati o altri materiali che l'Utente sceglie di scaricare, registrare o modificare tramite il Software.</li>
</ul>
<h2 id="s2">2. Accettazione dei termini</h2>
<p>L'installazione, l'attivazione o l'uso del Software costituiscono accettazione integrale e incondizionata dei presenti Termini. Se l'Utente non accetta una qualsiasi disposizione, deve interrompere l'uso del Software, disinstallarlo e richiedere il rimborso (vedi §13) entro i termini previsti.</p>
<h2 id="s3">3. Licenza d'uso (EULA)</h2>
<h3>3.1 Concessione</h3>
<p>L'Editore concede all'Utente una licenza <strong>personale, non esclusiva, non trasferibile, non sublicenziabile e revocabile</strong> per:</p>
<ul>
<li>Installare il Software su un massimo di <strong>3 (tre) dispositivi</strong> di proprieta' o sotto il controllo esclusivo dell'Utente;</li>
<li>Utilizzare il Software per finalita' strettamente <strong>personali e non commerciali</strong>;</li>
<li>Ricevere gli aggiornamenti rilasciati dall'Editore secondo §14.</li>
</ul>
<h3>3.2 Durata</h3>
<p>La Licenza e' a tempo indeterminato ("a vita") salvo i casi di risoluzione descritti al §15.</p>
<h3>3.3 Proprieta' intellettuale</h3>
<p>Il Software resta di esclusiva proprieta' intellettuale dell'Editore. La Licenza concede solo un diritto d'uso e non trasferisce alcun diritto di proprieta' all'Utente. Marchi, design, codice sorgente e documentazione sono protetti dalle leggi sul diritto d'autore italiane e internazionali.</p>
<h2 id="s4">4. Restrizioni d'uso</h2>
<p>L'Utente <strong>NON</strong> puo':</p>
<ul>
<li>Copiare, distribuire, vendere, affittare, prestare, sublicenziare o rendere altrimenti disponibile il Software a terzi;</li>
<li>Decompilare, disassemblare, fare reverse-engineering o tentare di ricavare il codice sorgente, salvo nei limiti consentiti da norme inderogabili (es. art. 64-quater l.d.a.);</li>
<li>Rimuovere, alterare od oscurare avvisi di copyright, marchi o note legali presenti nel Software;</li>
<li>Aggirare, disattivare o manomettere i meccanismi di attivazione della Licenza;</li>
<li>Condividere la propria chiave di licenza o farne uso simultaneo su un numero di dispositivi superiore a quello consentito;</li>
<li>Usare il Software per scopi illeciti o per agevolare la violazione di diritti di terzi.</li>
</ul>
<h2 id="s5">5. Acceptable Use Policy</h2>
<div class="warn-box">
<p><strong>MusicTools e' uno strumento tecnico di automazione.</strong> Funziona allo stesso modo di un browser o di un registratore audio: e' l'Utente a scegliere quali contenuti acquisire e da quali fonti.</p>
</div>
<h3>5.1 Usi consentiti</h3>
<p>L'Utente puo' utilizzare il Software esclusivamente per:</p>
<ul>
<li>Scaricare, registrare o modificare Contenuti <strong>di cui possiede i diritti</strong> (es. opere proprie, contenuti acquistati legalmente, materiale di pubblico dominio, contenuti rilasciati con licenza permissiva come Creative Commons);</li>
<li>Effettuare copie di backup di Contenuti gia' in suo legittimo possesso, nei limiti previsti dalla legge (es. copia privata, art. 71-sexies l.d.a.);</li>
<li>Fini di studio, ricerca o uso personale non commerciale, sempre nel rispetto del diritto d'autore.</li>
</ul>
<h3>5.2 Usi vietati</h3>
<p>E' espressamente vietato usare il Software per:</p>
<ul>
<li>Scaricare o registrare Contenuti coperti da copyright senza l'autorizzazione del titolare dei diritti;</li>
<li>Aggirare misure tecnologiche di protezione (DRM) imposte sui Contenuti, anche quando il Software permettesse tecnicamente di farlo;</li>
<li>Distribuire, rivendere, esibire pubblicamente o monetizzare Contenuti acquisiti tramite il Software;</li>
<li>Violare i Termini di servizio delle piattaforme di origine (es. YouTube ToS, Spotify ToS, ecc.);</li>
<li>Qualsiasi attivita' che configuri reato o illecito civile secondo la legge italiana o secondo quella della giurisdizione dell'Utente.</li>
</ul>
<h2 id="s6">6. Copyright e responsabilita dell'utente</h2>
<p>L'Editore <strong>non possiede, non ospita, non distribuisce alcun contenuto audio o video</strong>. Il Software accede esclusivamente alle risorse pubbliche o private a cui l'Utente decide di puntare.</p>
<p>L'<strong>Utente e' l'unico responsabile</strong>:</p>
<ul>
<li>Della scelta dei Contenuti da scaricare, registrare o modificare;</li>
<li>Del rispetto dei diritti d'autore, dei diritti connessi e dei diritti di immagine relativi a tali Contenuti;</li>
<li>Del rispetto dei Termini di servizio delle piattaforme di origine;</li>
<li>Dell'uso che fa dei Contenuti dopo averli acquisiti tramite il Software.</li>
</ul>
<p>L'Editore non puo' essere ritenuto responsabile in alcuna forma per l'uso che l'Utente fa del Software o dei Contenuti. Eventuali violazioni del diritto d'autore commesse dall'Utente restano di sua esclusiva responsabilita' civile, penale e amministrativa.</p>
<h2 id="s7">7. Marchi e affiliazioni con terze parti</h2>
<p>I marchi <strong>Spotify</strong>, <strong>YouTube</strong>, <strong>SoundCloud</strong>, <strong>TikTok</strong>, <strong>Instagram</strong>, <strong>Facebook</strong>, <strong>Apple Music</strong> e qualsiasi altro marchio menzionato sono di proprieta' dei rispettivi titolari.</p>
<p>MusicTools <strong>non e' affiliato, sponsorizzato, autorizzato, supportato</strong> ne' in alcun modo connesso ad alcuna di queste societa'. Le menzioni di tali marchi nel sito, nella documentazione e nell'interfaccia del Software hanno scopo esclusivamente descrittivo (indicare la compatibilita' tecnica del Software con tali servizi).</p>
<h2 id="s8">8. Procedura DMCA / segnalazioni</h2>
<p>Sebbene l'Editore non distribuisca contenuti, accoglie eventuali segnalazioni ai sensi della normativa internazionale sul copyright (incluso il Digital Millennium Copyright Act statunitense). Le richieste vanno inviate a <a href="mailto:info@djluza.com">info@djluza.com</a> con:</p>
<ul>
<li>Identificazione del titolare dei diritti e prova della titolarita';</li>
<li>Identificazione del materiale ritenuto in violazione;</li>
<li>Dichiarazione di buona fede e firma elettronica.</li>
</ul>
<h2 id="s9">9. Esclusione di garanzie</h2>
<p>Il Software e' fornito <strong>"COSI' COM'E'" e "COME DISPONIBILE"</strong> ("AS IS" e "AS AVAILABLE"), senza alcuna garanzia espressa o implicita di qualsiasi tipo, ivi incluse, a titolo non esaustivo, le garanzie di:</p>
<ul>
<li>Idoneita' a uno scopo specifico;</li>
<li>Commerciabilita';</li>
<li>Non violazione di diritti di terzi;</li>
<li>Funzionamento ininterrotto, privo di errori o privo di malware;</li>
<li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li>
</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><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>
<p>Nei limiti massimi consentiti dalla legge applicabile, in nessun caso l'Editore sara' responsabile verso l'Utente o verso terzi per:</p>
<ul>
<li>Danni diretti, indiretti, incidentali, speciali, consequenziali, esemplari o punitivi;</li>
<li>Perdita di dati, profitti, ricavi, opportunita' commerciali, avviamento, reputazione;</li>
<li>Interruzione dell'attivita';</li>
<li>Costi di sostituzione di beni o servizi;</li>
<li>Pretese o azioni di terzi conseguenti all'uso del Software da parte dell'Utente.</li>
</ul>
<p>La responsabilita' complessiva dell'Editore, per qualsiasi titolo o causa, e' in ogni caso limitata all'<strong>importo effettivamente corrisposto dall'Utente per la Licenza nei 12 (dodici) mesi precedenti all'evento dannoso</strong>.</p>
<p>Le limitazioni sopra riportate non si applicano nei casi di dolo o colpa grave, ne' nei casi in cui la legge applicabile non consenta tale limitazione (es. art. 1229 c.c.).</p>
<h2 id="s11">11. Manleva (indemnification)</h2>
<p>L'Utente si impegna a manlevare, difendere e tenere indenne l'Editore (e i suoi eventuali collaboratori, fornitori, agenti) da qualsiasi pretesa, azione, richiesta di risarcimento, costo, spesa legale, sanzione o danno avanzati da terzi e derivanti da:</p>
<ul>
<li>L'uso del Software da parte dell'Utente in violazione dei presenti Termini;</li>
<li>La violazione, da parte dell'Utente, di diritti d'autore, diritti connessi, diritti di immagine, marchi o altri diritti di terzi nell'acquisizione o nell'uso di Contenuti;</li>
<li>La violazione dei Termini di servizio delle piattaforme di origine;</li>
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
</ul>
<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>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>
<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>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>
<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>Il supporto e' fornito via email a <a href="mailto:info@djluza.com">info@djluza.com</a>, con risposta entro tempi ragionevoli e in lingua italiana o inglese.</p>
<h2 id="s15">15. Risoluzione</h2>
<p>L'Editore puo' risolvere immediatamente la Licenza, senza preavviso e senza obbligo di rimborso, qualora l'Utente:</p>
<ul>
<li>Violi una qualsiasi disposizione dei presenti Termini;</li>
<li>Usi il Software per attivita' illecite o per violare diritti di terzi;</li>
<li>Tenti di aggirare i meccanismi di attivazione o condivida la propria chiave;</li>
<li>Inoltri un chargeback non giustificato successivamente al periodo di recesso.</li>
</ul>
<p>A seguito della risoluzione, l'Utente deve cessare l'uso del Software e disinstallarlo. Le disposizioni di cui ai §6, §9, §10, §11 sopravvivono alla risoluzione.</p>
<h2 id="s16">16. Modifiche ai termini</h2>
<p>L'Editore puo' aggiornare i presenti Termini in qualsiasi momento per ragioni tecniche, normative o operative. La versione vigente e' sempre consultabile a questa URL. Per modifiche sostanziali sara' inviata notifica via email agli Utenti registrati con almeno 30 giorni di preavviso.</p>
<h2 id="s17">17. Legge applicabile e foro competente</h2>
<p>I presenti Termini sono regolati dalla legge italiana. Per qualunque controversia il foro competente e' quello del luogo di residenza dell'Utente consumatore, ove inderogabile per legge; in caso contrario, foro esclusivo del Tribunale di Chieti, Italia.</p>
<p>Resta salva la possibilita' per l'Utente consumatore residente nell'Unione Europea di rivolgersi alla piattaforma ODR della Commissione Europea: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">ec.europa.eu/consumers/odr</a>.</p>
<h2 id="s18">18. Contatti</h2>
<p>Per qualsiasi richiesta relativa ai presenti Termini scrivere a <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
<p style="margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--border); color: var(--text-dim); font-size: 13px;">
<strong>Disclaimer.</strong> Il presente documento e' un modello orientativo. L'Editore raccomanda all'Utente professionale di consultare un legale qualificato per verificare la conformita' a normative specifiche del proprio settore o paese.
</p>
</main>
</body>
</html>
+42 -2
View File
@@ -1,9 +1,49 @@
"""MusicDownload — Entry point (pywebview + WebKit/EdgeChromium).""" """MusicTools — Entry point (pywebview + WebKit/EdgeChromium)."""
import os 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
@@ -23,7 +63,7 @@ def main():
index_path = _resource_path("webui", "index.html") index_path = _resource_path("webui", "index.html")
window = webview.create_window( window = webview.create_window(
title="MusicDownload", title="MusicTools",
url=index_path, url=index_path,
js_api=api, js_api=api,
width=1080, width=1080,
+4 -1
View File
@@ -1,7 +1,10 @@
pywebview>=5.0 pywebview>=5.0
requests>=2.31.0 requests>=2.31.0
yt-dlp>=2024.0.0 yt-dlp>=2024.0.0
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" pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
pythonnet==3.0.5 ; sys_platform == "win32"
clr-loader==0.2.7.post0 ; sys_platform == "win32"
+35
View File
@@ -0,0 +1,35 @@
# Copia questo file in `.env` sul server (non committarlo!) e compila.
# ---- Server ----
PORT=4002
HOST=127.0.0.1
LATEST_VERSION=v1.5.2
PUBLIC_BASE_URL=https://musictools.djluza.com
# ---- DB (MariaDB locale) ----
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=musictools_licenses
DB_USER=musictools
DB_PASS=cambiami
# ---- Licenze ----
JWT_SECRET=cambiami_con_openssl_rand_base64_32
MAX_ACTIVATIONS=3
TOKEN_TTL_DAYS=30
DOWNLOAD_URL_TTL_SECONDS=300
# ---- Lemon Squeezy ----
# Dal dashboard LS: Settings > Webhooks > Signing Secret
LEMONSQUEEZY_SIGNING_SECRET=cambiami
# ---- Resend ----
# Dal dashboard Resend: API Keys
RESEND_API_KEY=cambiami
EMAIL_FROM=MusicTools <noreply@djluza.com>
# ---- Storage builds ----
# Dove conservi i binari macOS/Windows da servire agli utenti.
# Default: ../builds rispetto a server/. Puoi metterlo dove vuoi
# purche' il processo Node abbia accesso lettura.
BUILDS_DIR=/home/musictools/builds
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
*.log
logs/
dist/
+215
View File
@@ -0,0 +1,215 @@
# MusicTools — License & Update API
Backend Node.js per gestire attivazione licenze, validazione e distribuzione binari MusicTools.
## Stack
- **Node.js 20 + Express 4**
- **MariaDB 10.11** (locale, localhost:3306)
- **PM2** per process management
- **Apache 2.4** come reverse proxy verso `127.0.0.1:4002`
- **Resend** per email transazionali
- **Lemon Squeezy** per i pagamenti (webhook -> `/api/webhook/lemonsqueezy`)
Tutto gira sull'hosting esistente (`musictools@musictools.djluza.com`), zero costi aggiuntivi.
## Struttura
```
server/
src/
server.js # Express app
db.js # pool mysql2
license.js # activate / validate / deactivate
updates.js # /api/latest + /api/download
lemonsqueezy.js # webhook ordini
email.js # Resend client
jwt.js # JWT HS256 senza dipendenze
migrate.js # runner SQL idempotente
migrations/
0001_init.sql # schema licenses / activations / releases
ecosystem.config.cjs # PM2
.env.example
package.json
```
## Setup iniziale sul server
Una volta sola, da SSH `musictools@musictools.djluza.com`:
### 1) Crea database e utente MariaDB
Sul server (richiede root o l'utente master DB del tuo hosting — di solito Virtualmin lo crea per te dal pannello "Edit Databases"):
```sql
CREATE DATABASE musictools_licenses CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'musictools'@'localhost' IDENTIFIED BY 'PASSWORD_FORTE';
GRANT ALL PRIVILEGES ON musictools_licenses.* TO 'musictools'@'localhost';
FLUSH PRIVILEGES;
```
In alternativa via Virtualmin: **Edit Databases → Create a new database**, poi tab **Manage** → crea utente con password.
### 2) Clona o sincronizza il codice
Due opzioni:
**A. Via git** (consigliato):
```bash
cd ~
git clone https://github.com/luzadev/musicdownload.git app-src
ln -s app-src/server api
cd api
npm install --production
```
**B. Via rsync da locale** (più semplice se preferisci non lasciare codice client sul server):
```bash
# Da Mac:
rsync -avz --exclude node_modules /Users/luciano/Downloads/Progetti2026/MusicDownload/server/ musictools@musictools.djluza.com:~/api/
# Poi SSH e:
cd ~/api && npm install --production
```
### 3) Configura .env
```bash
cd ~/api
cp .env.example .env
nano .env # compila tutti i valori — vedi sotto
```
Per generare `JWT_SECRET`:
```bash
openssl rand -base64 32
```
### 4) Migrate
```bash
cd ~/api
npm run migrate
```
Output atteso:
```
[migrate] applico 0001_init.sql
[migrate] ok 0001_init.sql
[migrate] done
```
### 5) Avvia con PM2
```bash
cd ~/api
mkdir -p logs
pm2 start ecosystem.config.cjs
pm2 save
# Test:
curl -s http://127.0.0.1:4002/api/health
# -> {"ok":true,"version":"v1.5.2"}
```
### 6) Apache reverse proxy
Devi dire ad Apache che le richieste a `musictools.djluza.com/api/*` vanno a `127.0.0.1:4002`.
**Via Virtualmin** (consigliato):
1. Vai su **Webmin → Servers → Apache Webserver**
2. Clicca sul VirtualHost di `musictools.djluza.com` (porta 443)
3. Sezione **Aliases and redirects** o **Edit Directives**, aggiungi prima di `</VirtualHost>`:
```apache
# MusicTools API
ProxyPreserveHost On
ProxyRequests Off
# Webhook: passa raw body senza alterazioni
<Location /api/>
ProxyPass http://127.0.0.1:4002/api/
ProxyPassReverse http://127.0.0.1:4002/api/
</Location>
```
4. Apply changes.
**Verifica moduli Apache attivi** (da SSH se hai sudo):
```bash
apache2ctl -M | grep -E 'proxy|proxy_http'
# Devono comparire proxy_module e proxy_http_module
```
Se mancano, abilitali (sudo richiesto):
```bash
sudo a2enmod proxy proxy_http
sudo systemctl reload apache2
```
### 7) Verifica end-to-end
Da qualsiasi posto:
```bash
curl -s https://musictools.djluza.com/api/health
# -> {"ok":true,"version":"v1.5.2"}
```
## Workflow pubblicazione release
1. GitHub Actions builda i due zip (gia in place).
2. Carica gli zip sul server in `~/builds/<version>/`:
```bash
ssh musictools@musictools.djluza.com 'mkdir -p ~/builds/v1.5.3'
scp MusicTools-macOS.zip musictools@musictools.djluza.com:~/builds/v1.5.3/
scp MusicTools-Windows.zip musictools@musictools.djluza.com:~/builds/v1.5.3/
```
3. Inserisci il record in MariaDB:
```bash
ssh musictools@musictools.djluza.com 'mariadb musictools_licenses' <<SQL
INSERT INTO releases (version, platform, file_path, size_bytes, sha256, notes, published_at)
VALUES ('v1.5.3', 'macos', 'v1.5.3/MusicTools-macOS.zip', 12345, '<sha256>', 'Note...', UNIX_TIMESTAMP()),
('v1.5.3', 'windows', 'v1.5.3/MusicTools-Windows.zip', 67890, '<sha256>', 'Note...', UNIX_TIMESTAMP());
SQL
```
4. Aggiorna `LATEST_VERSION` nel `.env` e `pm2 reload musictools-api`.
In futuro: script o workflow GitHub Actions che fa tutto in automatico.
## Operazioni quotidiane
| Cosa | Comando |
|---|---|
| Reload codice dopo deploy | `pm2 reload musictools-api` |
| Vedere log live | `pm2 logs musictools-api` |
| Stato | `pm2 status` |
| Errori recenti | `pm2 logs musictools-api --err --lines 100` |
| Restart hard | `pm2 restart musictools-api` |
| Stop | `pm2 stop musictools-api` |
| Console DB | `mariadb -u musictools -p musictools_licenses` |
## Endpoints
| Metodo | Path | Auth | Descrizione |
|---|---|---|---|
| POST | `/api/license/activate` | — | Attiva licenza, ritorna JWT |
| POST | `/api/license/validate` | — (token nel body) | Rivalida + ruota token |
| POST | `/api/license/deactivate` | — (token nel body) | Libera uno slot |
| GET | `/api/latest?platform=…` | Bearer token (opzionale) | Versione + URL download firmato |
| GET | `/api/download?file=…&exp=…&sig=…` | Firma HMAC | Stream del binario |
| POST | `/api/webhook/lemonsqueezy` | X-Signature HMAC | Crea licenza dopo ordine |
| GET | `/api/health` | — | Healthcheck |
## Backup
Aggiungi un cronjob daily:
```bash
# crontab -e
0 4 * * * mariadb-dump musictools_licenses | gzip > ~/backups/musictools-$(date +\%F).sql.gz && find ~/backups -name 'musictools-*.sql.gz' -mtime +30 -delete
```
## Costi
- **Hosting**: gia pagato (server condiviso esistente)
- **Lemon Squeezy**: 5% + $0.50 per transazione = ~5 EUR su un acquisto da 39,90 EUR
- **Resend**: free fino a 3k email/mese (= ~3k licenze/mese, oltre serve piano $20)
- **Totale fisso**: 0 EUR/mese
+31
View File
@@ -0,0 +1,31 @@
/**
* PM2 ecosystem per il backend MusicTools.
*
* Avvio: pm2 start ecosystem.config.cjs --env production
* Reload: pm2 reload musictools-api
* Logs: pm2 logs musictools-api
* Save: pm2 save && pm2 startup (al primo deploy, una sola volta)
*/
module.exports = {
apps: [
{
name: "musictools-api",
script: "src/server.js",
cwd: __dirname,
exec_mode: "fork", // 1 processo, basta per i nostri volumi
instances: 1,
autorestart: true,
max_restarts: 10,
max_memory_restart: "256M",
env: {
NODE_ENV: "production",
},
// Le env sensibili stanno in .env: dotenv le carica al boot
out_file: "logs/out.log",
error_file: "logs/err.log",
merge_logs: true,
time: true,
},
],
};
+49
View File
@@ -0,0 +1,49 @@
-- Schema MariaDB iniziale per MusicTools licenze.
-- Tutto utf8mb4 + InnoDB con FK abilitate.
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS licenses (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
license_key VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL,
status ENUM('active','revoked','refunded') NOT NULL DEFAULT 'active',
source VARCHAR(32) NULL,
order_id VARCHAR(64) NULL,
created_at BIGINT UNSIGNED NOT NULL,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_licenses_key (license_key),
KEY idx_licenses_email (email),
KEY idx_licenses_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS activations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
license_id INT UNSIGNED NOT NULL,
device_id VARCHAR(64) NOT NULL,
device_name VARCHAR(255) NULL,
app_version VARCHAR(32) NULL,
activated_at BIGINT UNSIGNED NOT NULL,
last_seen_at BIGINT UNSIGNED NOT NULL,
revoked_at BIGINT UNSIGNED NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_activations_lic_dev (license_id, device_id),
KEY idx_activations_license (license_id),
CONSTRAINT fk_activations_license
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS releases (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
version VARCHAR(32) NOT NULL,
platform ENUM('macos','windows') NOT NULL,
file_path VARCHAR(512) NOT NULL,
size_bytes BIGINT UNSIGNED NULL,
sha256 CHAR(64) NULL,
notes TEXT NULL,
published_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_releases_ver_plat (version, platform),
KEY idx_releases_platform_pub (platform, published_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,28 @@
SET NAMES utf8mb4;
ALTER TABLE licenses
ADD COLUMN plan VARCHAR(16) NULL AFTER status,
ADD COLUMN daily_limit INT UNSIGNED NULL AFTER plan,
ADD COLUMN subscription_id VARCHAR(64) NULL AFTER daily_limit,
ADD COLUMN current_period_end BIGINT UNSIGNED NULL AFTER subscription_id,
ADD COLUMN expires_at BIGINT UNSIGNED NULL AFTER current_period_end;
UPDATE licenses
SET plan = 'annual',
daily_limit = NULL,
expires_at = NULL
WHERE plan IS NULL;
ALTER TABLE licenses
ADD KEY idx_licenses_plan (plan),
ADD KEY idx_licenses_sub (subscription_id);
CREATE TABLE IF NOT EXISTS daily_usage (
license_id INT UNSIGNED NOT NULL,
day CHAR(10) NOT NULL,
count INT UNSIGNED NOT NULL DEFAULT 0,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (license_id, day),
CONSTRAINT fk_daily_usage_license
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+21
View File
@@ -0,0 +1,21 @@
{
"name": "musictools-api",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "License + update API per MusicTools (Node.js + Express + MariaDB)",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"migrate": "node src/migrate.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.21.0",
"mysql2": "^3.11.0"
}
}
+30
View File
@@ -0,0 +1,30 @@
import mysql from "mysql2/promise";
const pool = mysql.createPool({
host: process.env.DB_HOST || "127.0.0.1",
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
charset: "utf8mb4",
});
export async function query(sql, params = []) {
const [rows] = await pool.execute(sql, params);
return rows;
}
export async function one(sql, params = []) {
const rows = await query(sql, params);
return rows[0] || null;
}
export async function exec(sql, params = []) {
const [result] = await pool.execute(sql, params);
return result; // { insertId, affectedRows, ... }
}
export default pool;
+71
View File
@@ -0,0 +1,71 @@
/**
* Endpoint "1-click download" per i clienti.
*
* URL: GET /api/download/:platform?key=XXXX&email=YYY
*
* Verifica la coppia (key, email) contro la tabella licenses, recupera
* l'ultima release attiva per la piattaforma, genera un signed URL e
* fa redirect 302. Il link nell'email viene cliccato dall'utente: con
* un click parte il download.
*
* Sicurezza:
* - Niente token sulla URL: la chiave e' gia "il segreto"
* - URL signed con scadenza breve (default 5 min)
* - Nessuna info di licenza leakata in risposta (ritorna solo redirect o 401/404)
*/
import crypto from "node:crypto";
import { one } from "./db.js";
const DOWNLOAD_TTL = Number(process.env.DOWNLOAD_URL_TTL_SECONDS || 300);
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
function signPayload(payload, secret) {
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
}
const normEmail = (s) => String(s || "").trim().toLowerCase();
const normKey = (s) => String(s || "").trim().toUpperCase();
export async function downloadByKey(req, res) {
const platform = String(req.params.platform || "").toLowerCase();
if (platform !== "macos" && platform !== "windows") {
return res.status(400).type("text/plain")
.send("Piattaforma non valida. Usa /api/download/macos o /api/download/windows.");
}
const key = normKey(req.query.key);
const email = normEmail(req.query.email);
if (!key || !email) {
return res.status(400).type("text/plain")
.send("Mancano email o chiave. Controlla il link nella tua email di acquisto.");
}
const license = await one(
"SELECT id, status FROM licenses WHERE license_key=? AND email=? LIMIT 1",
[key, email],
);
if (!license) {
return res.status(401).type("text/plain")
.send("Email o chiave non valide. Verifica il link nell'email o scrivici a info@djluza.com.");
}
if (license.status !== "active") {
return res.status(403).type("text/plain")
.send("Licenza non piu' attiva (rimborsata o revocata).");
}
const release = await one(
`SELECT file_path FROM releases
WHERE platform=? ORDER BY published_at DESC LIMIT 1`,
[platform],
);
if (!release) {
return res.status(503).type("text/plain")
.send(`Build ${platform} non ancora disponibile. Riprova fra qualche ora o scrivici a info@djluza.com.`);
}
const exp = Math.floor(Date.now() / 1000) + DOWNLOAD_TTL;
const sig = signPayload(`${release.file_path}.${exp}`, process.env.JWT_SECRET);
const url = `${PUBLIC_BASE}/api/download?file=${encodeURIComponent(release.file_path)}&exp=${exp}&sig=${sig}`;
res.redirect(302, url);
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Invio email transazionali via Resend.
*
* sendLicenseEmail(to, key, { plan, expiresAt, periodEnd })
* - plan: codice piano ("basic" | "pro" | "premium" | "annual")
* - expiresAt: epoch seconds (annual one-time) o null
* - periodEnd: epoch seconds (subscription mensile) o null
*
* Variabili env richieste:
* RESEND_API_KEY dal dashboard Resend
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
*/
import { getPlan } from "./plans.js";
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
function fmtItalianDate(epochSeconds) {
if (!epochSeconds) return "";
const d = new Date(Number(epochSeconds) * 1000);
return new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
day: "2-digit",
month: "long",
year: "numeric",
}).format(d);
}
function describePlan(planCode, expiresAt, periodEnd) {
const p = getPlan(planCode);
if (!p) {
return {
name: "MusicTools",
summary: "La tua licenza e' attiva.",
};
}
const features = {
audio: "download brani audio",
video: "download video",
record: "registrazione audio",
metadata: "editor metadati",
upgrade: "upgrade qualita' libreria",
};
const featList = p.features.map((f) => features[f] || f).join(", ");
const limit = p.daily_limit === null
? "senza limiti giornalieri"
: `fino a ${p.daily_limit} download al giorno`;
let validity = "";
if (planCode === "annual" && expiresAt) {
validity = `Validita': 1 anno, scade il <strong>${fmtItalianDate(expiresAt)}</strong>.`;
} else if (p.is_subscription && periodEnd) {
validity = `Prossimo rinnovo: <strong>${fmtItalianDate(periodEnd)}</strong>.`;
}
return {
name: p.name,
summary: `Piano <strong>${p.name}</strong> &mdash; ${limit}. Funzioni incluse: ${featList}. ${validity}`,
};
}
export async function sendLicenseEmail(to, licenseKey, opts = {}) {
if (!process.env.RESEND_API_KEY) {
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
return;
}
const { plan: planCode, expiresAt, periodEnd } = opts;
const planInfo = describePlan(planCode, expiresAt, periodEnd);
const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`;
const macUrl = `${BASE}/api/download/macos?${q}`;
const winUrl = `${BASE}/api/download/windows?${q}`;
const html = `
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
<p style="font-size:15px;line-height:1.55;margin:0 0 14px">
${planInfo.summary}
</p>
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
Ecco la tua chiave di licenza. Conservala con cura &mdash; ti servira' per attivare l'app.
</p>
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
${licenseKey}
</p>
<p style="font-size:15px;margin:0 0 12px"><strong>Scarica l'app:</strong></p>
<table cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px">
<tr>
<td style="padding-right:10px">
<a href="${macUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
Scarica per Mac
</a>
</td>
<td>
<a href="${winUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
Scarica per Windows
</a>
</td>
</tr>
</table>
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
<li>Clicca uno dei bottoni qui sopra per scaricare l'installer</li>
<li>Installa l'app (su Mac: tasto destro &rarr; Apri &rarr; Apri; su Windows: Esegui comunque)</li>
<li>Apri MusicTools: la prima schermata ti chiede email e chiave</li>
<li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li>
</ol>
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
<p style="font-size:13px;color:#888;margin:8px 0 0">
Hai bisogno di riscaricare in futuro? Vai su <a href="${BASE}/download" style="color:#1db954">${BASE.replace(/^https?:\/\//, "")}/download</a> e inserisci email + chiave.
</p>
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
</div>
`;
const subject = planInfo.name === "MusicTools"
? "La tua licenza MusicTools"
: `La tua licenza MusicTools ${planInfo.name}`;
const resp = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: FROM,
to: [to],
subject,
html,
}),
});
if (!resp.ok) {
const txt = await resp.text();
throw new Error(`Resend ${resp.status}: ${txt}`);
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* JWT HS256 minimale (niente dipendenze). Stessa logica del worker
* Cloudflare precedente: stesso token formato, stessa firma, stessa
* verifica. L'app desktop non distingue.
*/
import crypto from "node:crypto";
function b64url(buf) {
return Buffer.from(buf).toString("base64")
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function b64urlDecode(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
s += "=".repeat((4 - (s.length % 4)) % 4);
return Buffer.from(s, "base64");
}
function hmac(secret, data) {
return crypto.createHmac("sha256", secret).update(data).digest();
}
export function signJwt(claims, secret) {
const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64url(JSON.stringify(claims));
const sig = b64url(hmac(secret, `${head}.${body}`));
return `${head}.${body}.${sig}`;
}
export function verifyJwt(token, secret) {
if (typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const [head, body, sig] = parts;
const expected = b64url(hmac(secret, `${head}.${body}`));
// confronto a tempo costante
if (sig.length !== expected.length) return null;
let diff = 0;
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
if (diff !== 0) return null;
try {
const claims = JSON.parse(b64urlDecode(body).toString("utf-8"));
if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) {
return null;
}
return claims;
} catch {
return null;
}
}
+337
View File
@@ -0,0 +1,337 @@
/**
* Webhook Lemon Squeezy.
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
*
* Eventi gestiti:
* - order_created : crea licenza ANNUAL (one-time) con expires_at = now+365d.
* Per le subscription ignora: il record nasce da subscription_created.
* - order_refunded : status -> refunded.
* - subscription_created : crea licenza con plan, subscription_id, current_period_end.
* - subscription_updated : aggiorna current_period_end e plan se cambiato.
* - subscription_payment_success : estende current_period_end al nuovo renews_at.
* - subscription_payment_failed : (no-op qui) lasciamo scadere current_period_end.
* - subscription_cancelled : niente, il periodo corrente resta valido fino a current_period_end.
* - subscription_expired : status -> revoked, l'utente perde l'accesso.
*
* Tutto idempotente: ogni evento si puo' rispedire senza spaccare nulla.
*/
import crypto from "node:crypto";
import { one, exec } from "./db.js";
import { generateLicenseKey } from "./license.js";
import { sendLicenseEmail } from "./email.js";
import { planByVariantId, getPlan, ANNUAL_TTL_SECONDS } from "./plans.js";
const now = () => Math.floor(Date.now() / 1000);
function hmacHex(secret, data) {
return crypto.createHmac("sha256", secret).update(data).digest("hex");
}
function timingSafe(a, b) {
if (a.length !== b.length) return false;
const A = Buffer.from(a), B = Buffer.from(b);
return crypto.timingSafeEqual(A, B);
}
/** Converte una data ISO (es. "2026-07-09T13:24:00Z") in epoch seconds, o null. */
function isoToEpoch(iso) {
if (!iso) return null;
const t = Date.parse(String(iso));
if (Number.isNaN(t)) return null;
return Math.floor(t / 1000);
}
/** Estrae variant_id dal payload, gestendo le diverse forme degli eventi. */
function extractVariantId(payload, attrs) {
// subscription_*: attrs.variant_id (diretto)
if (attrs.variant_id != null) return String(attrs.variant_id);
// order_created: attrs.first_order_item.variant_id
const item = attrs.first_order_item;
if (item && item.variant_id != null) return String(item.variant_id);
// alcune forme passano da relationships
const rel = payload?.data?.relationships?.variant?.data?.id;
if (rel) return String(rel);
return "";
}
/** Email cliente: nei subscription_* puo' essere customer_email. */
function extractEmail(attrs) {
const e = attrs.user_email || attrs.customer_email || attrs.email || "";
return String(e).trim().toLowerCase();
}
// ---- Handlers per famiglia evento -----------------------------------------
async function handleOrderCreated(payload, attrs, t) {
const email = extractEmail(attrs);
const orderId = String(payload?.data?.id || attrs.order_number || "");
if (!email || !orderId) {
return { status: 400, body: { error: "Missing email or order_id" } };
}
const variantId = extractVariantId(payload, attrs);
const planCode = planByVariantId(variantId);
const plan = planCode ? getPlan(planCode) : null;
// 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 } };
}
// 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 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 {
await exec(
`INSERT INTO licenses
(license_key, email, status, plan, daily_limit,
source, order_id, expires_at, created_at, updated_at)
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?)`,
[key, email, planForRecord, limit, orderId, expiresAt, t, t],
);
} catch (e) {
if (e?.code === "ER_DUP_ENTRY") {
return { status: 200, body: { ok: true, duplicate: true } };
}
throw e;
}
try {
await sendLicenseEmail(email, key, {
plan: planForRecord,
expiresAt: expiresAt,
});
} catch (e) {
console.error("[email] send failed:", e?.message || e);
}
return { status: 200, body: { ok: true, plan: planForRecord } };
}
async function handleOrderRefunded(payload, attrs, t) {
const orderId = String(payload?.data?.id || attrs.order_number || "");
if (!orderId) return { status: 400, body: { error: "Missing order_id" } };
await exec(
`UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`,
[t, orderId],
);
return { status: 200, body: { ok: true } };
}
async function handleSubscriptionCreated(payload, attrs, t) {
const email = extractEmail(attrs);
const subId = String(payload?.data?.id || attrs.subscription_id || "");
const variantId = extractVariantId(payload, attrs);
const planCode = planByVariantId(variantId);
if (!email || !subId) {
return { status: 400, body: { error: "Missing email or subscription_id" } };
}
if (!planCode) {
// Niente mapping -> non sappiamo che limiti applicare. Logghiamo e
// ritorniamo 200 cosi' LS non rispedisce all'infinito.
console.warn("[ls] subscription_created variant non mappato:", variantId, "email=", email);
return { status: 200, body: { ok: true, ignored: "unknown_variant" } };
}
const plan = getPlan(planCode);
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
const orderId = String(attrs.order_id || "");
const key = generateLicenseKey();
// Idempotenza: se ricevo lo stesso subscription_created due volte,
// riuso la licenza esistente.
const existing = await one(
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
[subId],
);
if (existing) {
await exec(
`UPDATE licenses
SET status='active', plan=?, daily_limit=?, current_period_end=?, updated_at=?
WHERE id=?`,
[planCode, plan.daily_limit, periodEnd, t, existing.id],
);
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
(license_key, email, status, plan, daily_limit,
source, order_id, subscription_id, current_period_end,
created_at, updated_at)
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?, ?)`,
[key, email, planCode, plan.daily_limit, orderId || null, subId, periodEnd, t, t],
);
} catch (e) {
if (e?.code === "ER_DUP_ENTRY") {
return { status: 200, body: { ok: true, duplicate: true } };
}
throw e;
}
try {
await sendLicenseEmail(email, key, { plan: planCode, periodEnd });
} catch (e) {
console.error("[email] send failed:", e?.message || e);
}
return { status: 200, body: { ok: true, plan: planCode } };
}
async function handleSubscriptionUpdated(payload, attrs, t) {
const subId = String(payload?.data?.id || "");
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
const variantId = extractVariantId(payload, attrs);
const planCode = planByVariantId(variantId);
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
if (planCode) {
const plan = getPlan(planCode);
await exec(
`UPDATE licenses
SET plan=?, daily_limit=?, current_period_end=?, updated_at=?
WHERE subscription_id=?`,
[planCode, plan.daily_limit, periodEnd, t, subId],
);
} else if (periodEnd) {
await exec(
`UPDATE licenses SET current_period_end=?, updated_at=? WHERE subscription_id=?`,
[periodEnd, t, subId],
);
}
return { status: 200, body: { ok: true } };
}
async function handleSubscriptionPaymentSuccess(payload, attrs, t) {
// L'evento di pagamento riferisce alla subscription via attrs.subscription_id
const subId = String(attrs.subscription_id || payload?.data?.id || "");
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
// Quando un rinnovo va a buon fine LS sposta avanti renews_at.
// Per essere sicuri leggiamo l'evento PIU' recente (subscription_updated
// viene inviato a stretto giro), oppure deduciamo: +30 giorni.
const periodEnd = isoToEpoch(attrs.renews_at)
|| isoToEpoch(attrs.created_at)
|| (t + 30 * 24 * 3600);
await exec(
`UPDATE licenses
SET status='active', current_period_end=?, updated_at=?
WHERE subscription_id=?`,
[periodEnd, t, subId],
);
return { status: 200, body: { ok: true } };
}
async function handleSubscriptionExpired(payload, attrs, t) {
const subId = String(payload?.data?.id || attrs.subscription_id || "");
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
await exec(
`UPDATE licenses SET status='revoked', updated_at=? WHERE subscription_id=?`,
[t, subId],
);
return { status: 200, body: { ok: true } };
}
// ---- Entry point ----------------------------------------------------------
export async function webhook(req, res) {
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
const sig = req.get("X-Signature") || "";
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
if (!sig || !secret) return res.status(400).json({ error: "Missing signature" });
const expected = hmacHex(secret, raw);
if (!timingSafe(sig, expected)) {
return res.status(401).json({ error: "Invalid signature" });
}
let payload;
try { payload = JSON.parse(raw); }
catch { return res.status(400).json({ error: "Invalid JSON" }); }
const eventName = payload?.meta?.event_name || "";
const attrs = payload?.data?.attributes || {};
const t = now();
let out;
try {
switch (eventName) {
case "order_created":
out = await handleOrderCreated(payload, attrs, t);
break;
case "order_refunded":
out = await handleOrderRefunded(payload, attrs, t);
break;
case "subscription_created":
out = await handleSubscriptionCreated(payload, attrs, t);
break;
case "subscription_updated":
case "subscription_resumed":
case "subscription_unpaused":
out = await handleSubscriptionUpdated(payload, attrs, t);
break;
case "subscription_payment_success":
out = await handleSubscriptionPaymentSuccess(payload, attrs, t);
break;
case "subscription_expired":
out = await handleSubscriptionExpired(payload, attrs, t);
break;
case "subscription_cancelled":
case "subscription_paused":
case "subscription_payment_failed":
case "subscription_payment_refunded":
// No-op: il record resta com'e' fino al prossimo evento che cambia stato.
out = { status: 200, body: { ok: true, ignored: eventName } };
break;
default:
out = { status: 200, body: { ok: true, ignored: eventName } };
}
} catch (e) {
console.error("[ls] handler error", eventName, e?.message || e);
return res.status(500).json({ error: "Internal error" });
}
return res.status(out.status).json(out.body);
}
+208
View File
@@ -0,0 +1,208 @@
import crypto from "node:crypto";
import { one, exec } from "./db.js";
import { signJwt, verifyJwt } from "./jwt.js";
import { getPlan, planSnapshot } from "./plans.js";
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
const now = () => Math.floor(Date.now() / 1000);
const normEmail = (s) => String(s || "").trim().toLowerCase();
const normKey = (s) => String(s || "").trim().toUpperCase();
/**
* Verifica che una licenza sia ancora "utilizzabile" oggi.
* Ritorna { ok: true } oppure { ok: false, error: "..." }.
*
* Casi gestiti:
* - annual one-time scaduto (expires_at < now)
* - subscription scaduta (current_period_end < now)
*/
function checkLicenseValidity(license, t = now()) {
if (license.status !== "active") {
return { ok: false, error: "Licenza non piu' valida (rimborsata o revocata)." };
}
if (license.expires_at && Number(license.expires_at) < t) {
return { ok: false, error: "L'abbonamento annuale e' scaduto. Riacquista per continuare." };
}
if (license.current_period_end && Number(license.current_period_end) < t) {
return { ok: false, error: "L'abbonamento e' scaduto o e' fallito il rinnovo. Verifica su Lemon Squeezy." };
}
return { ok: true };
}
async function issueToken(license, deviceId) {
const t = now();
const plan = planSnapshot(license.plan);
const claims = {
sub: String(license.id),
key_id: license.license_key,
email: license.email,
device_id: deviceId,
iat: t,
exp: t + TOKEN_TTL_DAYS * 86400,
};
if (plan) {
claims.plan = plan.code;
claims.plan_name = plan.name;
claims.daily_limit = plan.daily_limit;
claims.features = plan.features;
claims.is_subscription = plan.is_subscription;
}
if (license.expires_at) claims.expires_at = Number(license.expires_at);
if (license.current_period_end) claims.period_end = Number(license.current_period_end);
return signJwt(claims, process.env.JWT_SECRET);
}
const LICENSE_COLUMNS =
"id, license_key, email, status, plan, daily_limit, " +
"subscription_id, current_period_end, expires_at";
export async function activate(req, res) {
const { key, email, device_id, device_name, app_version } = req.body || {};
const K = normKey(key), E = normEmail(email);
const D = String(device_id || "").trim();
if (!K || !E || !D) {
return res.status(400).json({ error: "Missing key, email or device_id" });
}
const license = await one(
`SELECT ${LICENSE_COLUMNS} FROM licenses
WHERE license_key=? AND email=? LIMIT 1`,
[K, E],
);
if (!license) {
return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." });
}
const check = checkLicenseValidity(license);
if (!check.ok) return res.status(403).json({ error: check.error });
const t = now();
const existing = await one(
"SELECT id FROM activations WHERE license_id=? AND device_id=? LIMIT 1",
[license.id, D],
);
if (existing) {
await exec(
`UPDATE activations SET app_version=?, device_name=?, last_seen_at=?, revoked_at=NULL
WHERE id=?`,
[app_version || null, device_name || null, t, existing.id],
);
} else {
const row = await one(
"SELECT COUNT(*) AS n FROM activations WHERE license_id=? AND revoked_at IS NULL",
[license.id],
);
if ((row?.n || 0) >= MAX_ACTIVATIONS) {
return res.status(409).json({
error: `Hai gia attivato la licenza su ${MAX_ACTIVATIONS} dispositivi. Disattivane uno per usarla qui.`,
});
}
await exec(
`INSERT INTO activations
(license_id, device_id, device_name, app_version, activated_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[license.id, D, device_name || null, app_version || null, t, t],
);
}
const token = await issueToken(license, D);
const plan = planSnapshot(license.plan);
res.json({
token,
activated_at: t,
email: license.email,
plan,
expires_at: license.expires_at ? Number(license.expires_at) : null,
period_end: license.current_period_end ? Number(license.current_period_end) : null,
});
}
export async function validate(req, res) {
const { token, device_id, app_version } = req.body || {};
const T = String(token || "").trim();
const D = String(device_id || "").trim();
if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" });
const claims = verifyJwt(T, process.env.JWT_SECRET);
if (!claims) return res.status(401).json({ error: "Token invalido o scaduto" });
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
const license = await one(
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
[claims.sub],
);
if (!license) return res.status(401).json({ error: "Licenza non trovata" });
const check = checkLicenseValidity(license);
if (!check.ok) return res.status(401).json({ error: check.error });
const act = await one(
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
[license.id, D],
);
if (!act || act.revoked_at !== null) {
return res.status(401).json({ error: "Attivazione non trovata o revocata" });
}
await exec(
"UPDATE activations SET last_seen_at=?, app_version=? WHERE id=?",
[now(), app_version || null, act.id],
);
const fresh = await issueToken(license, D);
const plan = planSnapshot(license.plan);
res.json({
token: fresh,
email: license.email,
plan,
expires_at: license.expires_at ? Number(license.expires_at) : null,
period_end: license.current_period_end ? Number(license.current_period_end) : null,
});
}
export async function deactivate(req, res) {
const { token, device_id } = req.body || {};
const T = String(token || "").trim();
const D = String(device_id || "").trim();
if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" });
const claims = verifyJwt(T, process.env.JWT_SECRET);
if (!claims) return res.status(401).json({ error: "Token invalido" });
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
await exec(
`UPDATE activations SET revoked_at=?
WHERE license_id=? AND device_id=? AND revoked_at IS NULL`,
[now(), claims.sub, D],
);
res.json({ ok: true });
}
/**
* Helper interno: dato un token JWT valido, ritorna la riga licenze
* fresca dal DB (per quota checks, downloads, ecc.). Ritorna null se
* il token e' invalido o la licenza non e' piu' attiva.
*/
export async function licenseFromToken(token) {
const claims = verifyJwt(String(token || ""), process.env.JWT_SECRET);
if (!claims) return null;
const lic = await one(
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
[claims.sub],
);
if (!lic) return null;
const check = checkLicenseValidity(lic);
if (!check.ok) return null;
return { license: lic, claims };
}
// Esposto per uso interno (es. webhook genera chiave nuova)
export function generateLicenseKey() {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const buf = crypto.randomBytes(16);
const chars = Array.from(buf, (b) => alphabet[b % alphabet.length]);
return [chars.slice(0,4), chars.slice(4,8), chars.slice(8,12), chars.slice(12,16)]
.map((g) => g.join("")).join("-");
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Applica i file SQL in migrations/ in ordine alfabetico.
* Tiene traccia dei file gia eseguiti in una tabella `schema_migrations`.
*
* Run: npm run migrate
*/
import "dotenv/config";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pool, { query, exec } from "./db.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MIG_DIR = path.resolve(__dirname, "../migrations");
async function ensureMigrationsTable() {
await exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename VARCHAR(255) NOT NULL PRIMARY KEY,
applied_at BIGINT UNSIGNED NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
}
async function run() {
await ensureMigrationsTable();
const applied = new Set(
(await query("SELECT filename FROM schema_migrations")).map((r) => r.filename),
);
const files = fs.readdirSync(MIG_DIR)
.filter((f) => f.endsWith(".sql"))
.sort();
for (const f of files) {
if (applied.has(f)) {
console.log(`[migrate] skip ${f} (gia applicato)`);
continue;
}
const sql = fs.readFileSync(path.join(MIG_DIR, f), "utf-8");
console.log(`[migrate] applico ${f}`);
// mysql2 supporta multipleStatements ma e' rischioso; splittiamo manualmente.
const statements = sql
.split(/;\s*\n/)
.map((s) => s.trim())
.filter((s) => s.length > 0 && !s.startsWith("--"));
for (const stmt of statements) {
await exec(stmt);
}
await exec(
"INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
[f, Math.floor(Date.now() / 1000)],
);
console.log(`[migrate] ok ${f}`);
}
await pool.end();
console.log("[migrate] done");
}
run().catch((e) => {
console.error("[migrate] errore:", e);
process.exit(1);
});
+105
View File
@@ -0,0 +1,105 @@
/**
* Source of truth per i piani MusicTools.
*
* Mappa anche i variant_id di Lemon Squeezy -> plan code, cosi' il
* webhook puo' capire quale piano e' stato comprato senza if/else sparsi.
*
* I variant_id si trovano nel dashboard LS: Products -> click sul prodotto
* -> Variants -> "Edit" -> URL contiene /variants/<id>. Vanno messi in
* .env (LS_VARIANT_*); qui li leggiamo con fallback null.
*
* NOTA su 'features': non e' un set di "tab" ma un set di capability
* lato server. Il client mostra/nasconde le tab in base a queste.
* - "audio" : download brani (tab Brani)
* - "video" : download video YouTube/TikTok/IG/FB (tab Video)
* - "record" : registrazione audio (tab Registra)
* - "metadata" : editor metadati (tab Metadati)
* - "upgrade" : upgrade qualita' libreria (tab Upgrade)
*
* Limiti giornalieri (daily_limit, null = unlimited): contiamo come "uso"
* ogni avvio di un job di download (1 chiamata a /api/usage/consume) E
* ogni salvataggio metadati. La registrazione e' un'azione bound a 1
* file/giorno: pure 1.
*/
export const PLANS = {
basic: {
code: "basic",
name: "Basic",
price_eur: 2.99,
interval: "monthly",
daily_limit: 10,
features: ["audio"],
is_subscription: true,
ls_variant_env: "LS_VARIANT_BASIC",
},
pro: {
code: "pro",
name: "Pro",
price_eur: 5.99,
interval: "monthly",
daily_limit: 30,
features: ["audio", "video", "record", "metadata", "upgrade"],
is_subscription: true,
ls_variant_env: "LS_VARIANT_PRO",
},
premium: {
code: "premium",
name: "Premium",
price_eur: 9.99,
interval: "monthly",
daily_limit: 150,
features: ["audio", "video", "record", "metadata", "upgrade"],
is_subscription: true,
ls_variant_env: "LS_VARIANT_PREMIUM",
},
annual: {
code: "annual",
name: "Annual",
price_eur: 49.90,
interval: "annual_one_time",
daily_limit: null,
features: ["audio", "video", "record", "metadata", "upgrade"],
is_subscription: false,
ls_variant_env: "LS_VARIANT_ANNUAL",
},
};
export const PLAN_CODES = Object.keys(PLANS);
/** Ritorna il plan code mappato a un LS variant_id, o null se non noto. */
export function planByVariantId(variantId) {
const vid = String(variantId || "").trim();
if (!vid) return null;
for (const code of PLAN_CODES) {
const envName = PLANS[code].ls_variant_env;
const mapped = String(process.env[envName] || "").trim();
if (mapped && mapped === vid) return code;
}
return null;
}
export function getPlan(code) {
return PLANS[code] || null;
}
/** Forma stabile per inclusione nei JWT claims / risposte API. */
export function planSnapshot(code) {
const p = getPlan(code);
if (!p) return null;
return {
code: p.code,
name: p.name,
daily_limit: p.daily_limit,
features: p.features.slice(),
is_subscription: p.is_subscription,
};
}
export function hasFeature(code, feature) {
const p = getPlan(code);
return !!(p && p.features.includes(feature));
}
/** Durata annual one-time in secondi (365 giorni). */
export const ANNUAL_TTL_SECONDS = 365 * 24 * 3600;
+71
View File
@@ -0,0 +1,71 @@
import "dotenv/config";
import express from "express";
import * as license from "./license.js";
import * as updates from "./updates.js";
import * as ls from "./lemonsqueezy.js";
import * as dl from "./downloads.js";
import * as usage from "./usage.js";
const app = express();
// L'app sta dietro Apache reverse proxy: fidati di X-Forwarded-* dal localhost.
app.set("trust proxy", "loopback");
app.disable("x-powered-by");
// CORS minimale (solo per /api/*)
app.use("/api", (req, res, next) => {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.set("Access-Control-Allow-Headers", "Content-Type,Authorization");
if (req.method === "OPTIONS") return res.status(204).end();
next();
});
// Health check (no body parser necessario)
app.get("/api/health", (_req, res) => {
res.json({ ok: true, version: process.env.LATEST_VERSION || "" });
});
// WEBHOOK Lemon Squeezy: deve ricevere il body RAW per verificare la firma.
// Va registrato PRIMA del json parser globale.
app.post(
"/api/webhook/lemonsqueezy",
express.raw({ type: "application/json", limit: "1mb" }),
ls.webhook,
);
// JSON parser per tutti gli altri endpoint
app.use(express.json({ limit: "128kb" }));
// Licenze
app.post("/api/license/activate", license.activate);
app.post("/api/license/validate", license.validate);
app.post("/api/license/deactivate", license.deactivate);
// Quota giornaliera
app.get("/api/usage/status", usage.status);
app.post("/api/usage/consume", usage.consume);
// Aggiornamenti + download firmato
app.get("/api/latest", updates.latest);
app.get("/api/download", updates.download);
// 1-click download dall'email (key+email come query -> redirect signed)
app.get("/api/download/:platform", dl.downloadByKey);
// 404 JSON solo per /api/*
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
// Error handler
app.use((err, _req, res, _next) => {
console.error("[unhandled]", err);
if (res.headersSent) return;
res.status(500).json({ error: "Internal error" });
});
const PORT = Number(process.env.PORT || 4002);
const HOST = process.env.HOST || "127.0.0.1";
app.listen(PORT, HOST, () => {
console.log(`[musictools-api] listening on ${HOST}:${PORT}`);
});
+101
View File
@@ -0,0 +1,101 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { one } from "./db.js";
import { verifyJwt } from "./jwt.js";
const BUILDS_DIR = process.env.BUILDS_DIR || path.resolve(process.cwd(), "../builds");
const DOWNLOAD_TTL = Number(process.env.DOWNLOAD_URL_TTL_SECONDS || 300);
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
function signPayload(payload, secret) {
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
}
function buildDownloadUrl(filePath) {
const exp = Math.floor(Date.now() / 1000) + DOWNLOAD_TTL;
const sig = signPayload(`${filePath}.${exp}`, process.env.JWT_SECRET);
return `${PUBLIC_BASE}/api/download?file=${encodeURIComponent(filePath)}&exp=${exp}&sig=${sig}`;
}
// GET /api/latest?platform=macos|windows&current=v1.5.2
export async function latest(req, res) {
const platform = String(req.query.platform || "").toLowerCase();
if (platform !== "macos" && platform !== "windows") {
return res.status(400).json({ error: "platform must be macos or windows" });
}
const row = await one(
`SELECT version, platform, file_path, size_bytes, sha256, notes, published_at
FROM releases
WHERE platform=?
ORDER BY published_at DESC
LIMIT 1`,
[platform],
);
if (!row) {
return res.json({
version: process.env.LATEST_VERSION || "",
notes: "",
download_url: "",
requires_license: true,
});
}
// Auth opzionale (Bearer): senza, niente download URL
const auth = req.get("Authorization") || "";
let licensed = false;
if (auth.startsWith("Bearer ")) {
const claims = verifyJwt(auth.slice(7).trim(), process.env.JWT_SECRET);
licensed = !!claims;
}
res.json({
version: row.version,
notes: row.notes || "",
sha256: row.sha256 || "",
size_bytes: Number(row.size_bytes || 0),
download_url: licensed ? buildDownloadUrl(row.file_path) : "",
requires_license: !licensed,
});
}
// GET /api/download?file=...&exp=...&sig=...
// Verifica firma e stream del file da disco.
export async function download(req, res) {
const file = String(req.query.file || "");
const exp = Number(req.query.exp || 0);
const sig = String(req.query.sig || "");
if (!file || !exp || !sig) return res.status(400).send("Missing params");
const expected = signPayload(`${file}.${exp}`, process.env.JWT_SECRET);
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("Invalid signature");
}
if (exp < Math.floor(Date.now() / 1000)) {
return res.status(410).send("URL expired");
}
// Sicurezza path: il file deve trovarsi sotto BUILDS_DIR.
// 'file' arriva come "v1.5.3/MusicTools-macOS.zip"
const abs = path.resolve(BUILDS_DIR, file);
if (!abs.startsWith(path.resolve(BUILDS_DIR) + path.sep)) {
return res.status(403).send("Forbidden");
}
if (!fs.existsSync(abs)) {
return res.status(404).send("File not found");
}
const name = path.basename(abs);
const ext = path.extname(name).toLowerCase();
const contentType = ext === ".dmg"
? "application/x-apple-diskimage"
: ext === ".exe"
? "application/vnd.microsoft.portable-executable"
: "application/zip";
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
fs.createReadStream(abs).pipe(res);
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Quota giornaliera per piano.
*
* Il client chiama:
* - GET /api/usage/status (Bearer JWT) -> solo lettura, no incremento
* - POST /api/usage/consume (Bearer JWT) -> +1 al contatore, ritorna esito
*
* Il "giorno" e' YYYY-MM-DD nella timezone Europe/Rome. In questo modo
* tutti i clienti hanno lo stesso reset (mezzanotte di Roma) — semplifica
* il marketing ("10 download al giorno, resettati a mezzanotte").
*/
import { one, exec } from "./db.js";
import { licenseFromToken } from "./license.js";
import { getPlan, planSnapshot } from "./plans.js";
const now = () => Math.floor(Date.now() / 1000);
/** YYYY-MM-DD nella timezone Europe/Rome, partendo da un istante epoch. */
export function romeDay(epochSeconds = Math.floor(Date.now() / 1000)) {
const d = new Date(epochSeconds * 1000);
// Intl con time zone -> formatta in YYYY-MM-DD senza problemi DST.
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Rome",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// en-CA -> "2026-06-09"
return fmt.format(d);
}
function bearer(req) {
const auth = req.get("Authorization") || "";
return auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
}
async function loadAuthorized(req, res) {
const token = bearer(req) || String(req.body?.token || "");
if (!token) {
res.status(401).json({ error: "Missing token" });
return null;
}
const auth = await licenseFromToken(token);
if (!auth) {
res.status(401).json({ error: "Licenza non valida o scaduta" });
return null;
}
return auth;
}
/** Risposta compatta usata da entrambi gli endpoint. */
function buildResponse(plan, limit, used) {
const remaining = limit === null ? null : Math.max(0, limit - used);
return {
plan,
used,
limit, // null = illimitato
remaining, // null = illimitato
day: romeDay(),
};
}
/** Quanti download ha gia' fatto la licenza oggi (Europe/Rome). */
async function readUsedToday(licenseId, day) {
const row = await one(
"SELECT count FROM daily_usage WHERE license_id=? AND day=?",
[licenseId, day],
);
return Number(row?.count || 0);
}
export async function status(req, res) {
const auth = await loadAuthorized(req, res);
if (!auth) return;
const { license } = auth;
const plan = planSnapshot(license.plan);
const limit = plan?.daily_limit ?? null;
const day = romeDay();
const used = limit === null ? 0 : await readUsedToday(license.id, day);
res.json(buildResponse(plan, limit, used));
}
/**
* Incrementa di 1 il contatore del giorno corrente per la licenza,
* rifiutando con 429 se gia' al limite. Lo facciamo con una UPDATE
* condizionale + INSERT-or-no-op cosi' e' atomico anche sotto carico.
*/
export async function consume(req, res) {
const auth = await loadAuthorized(req, res);
if (!auth) return;
const { license } = auth;
const plan = planSnapshot(license.plan);
if (!plan) return res.status(403).json({ error: "Piano non riconosciuto" });
// (Opzionale) il client puo' passare {feature: "video"} per chiarezza,
// ma noi gating-amo gia' lato client e basta avere un piano valido qui.
const feature = String(req.body?.feature || "").trim();
if (feature && !plan.features.includes(feature)) {
return res.status(403).json({
error: `Il piano ${plan.name} non include questa funzione.`,
plan,
});
}
const limit = plan.daily_limit;
const day = romeDay();
const t = now();
// Unlimited (annual): nessun gate, conta comunque per le statistiche.
if (limit === null) {
await exec(
`INSERT INTO daily_usage (license_id, day, count, updated_at)
VALUES (?, ?, 1, ?)
ON DUPLICATE KEY UPDATE count = count + 1, updated_at = VALUES(updated_at)`,
[license.id, day, t],
);
const used = await readUsedToday(license.id, day);
return res.json({ ...buildResponse(plan, limit, used), allowed: true });
}
// Limited: prova incremento condizionato. Se la riga non esiste,
// INSERT iniziale (count=1). Se esiste e count<limit -> +1, altrimenti
// affectedRows=0 e ritorniamo 429.
const upd = await exec(
`UPDATE daily_usage
SET count = count + 1, updated_at = ?
WHERE license_id = ? AND day = ? AND count < ?`,
[t, license.id, day, limit],
);
if (upd.affectedRows === 0) {
// Due casi: riga non esiste (mai usato oggi) oppure gia' al limite.
const existing = await one(
"SELECT count FROM daily_usage WHERE license_id=? AND day=?",
[license.id, day],
);
if (!existing) {
try {
await exec(
`INSERT INTO daily_usage (license_id, day, count, updated_at)
VALUES (?, ?, 1, ?)`,
[license.id, day, t],
);
return res.json({ ...buildResponse(plan, limit, 1), allowed: true });
} catch (e) {
if (e?.code !== "ER_DUP_ENTRY") throw e;
// race: qualcun altro l'ha inserita -> rileggi
}
}
const used = await readUsedToday(license.id, day);
return res.status(429).json({
...buildResponse(plan, limit, used),
allowed: false,
error: `Limite giornaliero raggiunto (${limit}/${limit}). Riprova dopo mezzanotte o passa a un piano superiore.`,
});
}
const used = await readUsedToday(license.id, day);
res.json({ ...buildResponse(plan, limit, used), allowed: true });
}
+736 -5
View File
@@ -1,5 +1,5 @@
/* =============================== /* ===============================
MusicDownload — Spotify-like UI MusicTools — Spotify-like UI
=============================== */ =============================== */
:root { :root {
@@ -30,6 +30,18 @@
--pink-hover: #F472B6; --pink-hover: #F472B6;
--pink-dim: #831843; --pink-dim: #831843;
--amber: #F59E0B;
--amber-hover: #FBBF24;
--amber-dim: #78350F;
--rec-red: #EF4444;
--rec-red-hover: #F87171;
--rec-red-dim: #7F1D1D;
--indigo: #6366F1;
--indigo-hover: #818CF8;
--indigo-dim: #312E81;
--red: #E22134; --red: #E22134;
--red-hover: #FF4055; --red-hover: #FF4055;
@@ -46,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;
@@ -182,6 +226,67 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
margin-top: 2px; margin-top: 2px;
} }
/* ===============================
QUOTA BOX (sidebar)
=============================== */
.quota-box {
margin: 10px 8px 0;
padding: 10px 12px;
background: rgba(29, 185, 84, 0.08);
border: 1px solid rgba(29, 185, 84, 0.25);
border-radius: 10px;
text-align: center;
}
.quota-box[hidden] { display: none; }
.quota-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
color: var(--text-3);
text-transform: uppercase;
}
.quota-value {
font-size: 22px;
font-weight: 800;
color: var(--text);
margin: 2px 0;
}
.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
.quota-plan {
font-size: 11px;
color: var(--green);
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.quota-upgrade {
margin-top: 8px;
background: var(--green);
border: none;
color: #000;
font-weight: 700;
font-size: 11px;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
width: 100%;
}
.quota-upgrade[hidden] { display: none; }
.quota-upgrade:hover { filter: brightness(1.1); }
/* Modal Upgrade — testo prose, non monospace */
#upgradeModal .modal-body {
font-family: inherit;
font-size: 14px;
color: var(--text);
white-space: normal;
}
#upgradeModal .modal-body p { margin: 0 0 10px; line-height: 1.5; }
#upgradeModal .modal-body .modal-hint {
color: var(--text-2);
font-size: 13px;
}
/* =============================== /* ===============================
MAIN MAIN
=============================== */ =============================== */
@@ -233,6 +338,18 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%); background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%);
} }
.hero-amber {
background: linear-gradient(135deg, #F59E0B 0%, #78350F 60%, #3B1E07 100%);
}
.hero-red {
background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%);
}
.hero-indigo {
background: linear-gradient(135deg, #4F46E5 0%, #312E81 60%, #1E1B4B 100%);
}
.hero-content { position: relative; z-index: 2; max-width: 70%; } .hero-content { position: relative; z-index: 2; max-width: 70%; }
.hero-eyebrow { .hero-eyebrow {
@@ -246,6 +363,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.hero-eyebrow.blue { color: var(--blue); } .hero-eyebrow.blue { color: var(--blue); }
.hero-eyebrow.purple { color: var(--purple); } .hero-eyebrow.purple { color: var(--purple); }
.hero-eyebrow.pink { color: var(--pink-hover); } .hero-eyebrow.pink { color: var(--pink-hover); }
.hero-eyebrow.amber { color: var(--amber-hover); }
.hero-eyebrow.red { color: var(--rec-red-hover); }
.hero-eyebrow.indigo { color: var(--indigo-hover); }
.hero-title { .hero-title {
font-size: 36px; font-size: 36px;
@@ -463,6 +583,25 @@ input[type="number"]::-webkit-inner-spin-button {
color: #fff; color: #fff;
} }
.btn-amber {
background: var(--amber);
color: #000;
}
.btn-red {
background: var(--rec-red);
color: #fff;
}
.btn-red.recording {
animation: pulseRed 1.2s ease-in-out infinite;
}
@keyframes pulseRed {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
.btn-success { .btn-success {
background: #00CEC9; background: #00CEC9;
color: #000; color: #000;
@@ -578,30 +717,79 @@ input[type="number"]::-webkit-inner-spin-button {
.log .l-warn { color: #FDCB6E; } .log .l-warn { color: #FDCB6E; }
.log .l-muted { color: var(--text-3); } .log .l-muted { color: var(--text-3); }
/* ===============================
SEGMENTED CONTROL
=============================== */
.segmented {
display: inline-flex;
background: var(--bg-input);
padding: 4px;
border-radius: var(--r-pill);
gap: 2px;
}
.seg-btn {
border: none;
background: transparent;
color: var(--text-2);
padding: 8px 18px;
border-radius: var(--r-pill);
cursor: pointer;
font-weight: 700;
font-size: 13px;
transition: background-color 0.15s, color 0.15s;
}
.seg-btn:hover:not(.active) { color: var(--text); }
.seg-btn.active {
background: var(--pink);
color: #fff;
}
/* =============================== /* ===============================
HINT BOX HINT BOX
=============================== */ =============================== */
.hint { .hint {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 10px; gap: 12px;
margin-top: 4px; margin-top: 4px;
padding: 10px 14px; padding: 12px 14px;
background: rgba(236, 72, 153, 0.08); background: rgba(236, 72, 153, 0.08);
border: 1px solid rgba(236, 72, 153, 0.2); border: 1px solid rgba(236, 72, 153, 0.2);
border-radius: var(--r-sm); border-radius: var(--r-sm);
color: var(--text-2); color: var(--text-2);
font-size: 12.5px; font-size: 12.5px;
line-height: 1.55;
} }
.hint-ico { .hint-ico {
color: var(--pink-hover); color: var(--pink-hover);
font-size: 14px; font-size: 14px;
font-weight: 800; font-weight: 800;
margin-top: 1px;
} }
.hint strong { color: var(--text); font-weight: 700; } .hint strong { color: var(--text); font-weight: 700; }
.hint code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
color: var(--text);
}
.hint a {
color: var(--green-hover);
text-decoration: none;
font-weight: 700;
}
.hint a:hover { text-decoration: underline; }
/* =============================== /* ===============================
CHECKBOX CHECKBOX
=============================== */ =============================== */
@@ -685,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 {
@@ -819,3 +1019,534 @@ input[type="number"]::-webkit-inner-spin-button {
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); } .log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
.main::-webkit-scrollbar-thumb { border-color: var(--bg); } .main::-webkit-scrollbar-thumb { border-color: var(--bg); }
/* ===============================
METADATA EDITOR
=============================== */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 18px;
}
.form-grid .field { margin-bottom: 0; }
.form-grid .field.full { grid-column: 1 / -1; }
.textarea {
border-radius: var(--r-md);
padding: 12px 16px;
resize: vertical;
min-height: 70px;
font-family: inherit;
line-height: 1.5;
}
.meta-info {
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--divider);
color: var(--text-2);
font-size: 12.5px;
}
.meta-info span strong {
color: var(--text);
font-weight: 700;
margin-right: 6px;
}
.cover-card {
display: grid;
grid-template-columns: 180px 1fr;
gap: 24px;
align-items: start;
}
.cover-preview {
width: 180px;
height: 180px;
border-radius: var(--r-md);
background: var(--bg-card-2);
border: 1px dashed var(--bg-input-hover);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-empty {
color: var(--text-3);
font-size: 12px;
text-align: center;
padding: 0 12px;
}
.cover-actions {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.cover-hint {
color: var(--text-3);
font-size: 12px;
margin-top: 4px;
}
.cover-hint.set { color: var(--amber-hover); font-weight: 700; }
.cover-hint.remove { color: var(--red); font-weight: 700; }
@media (max-width: 720px) {
.form-grid { grid-template-columns: 1fr; }
.cover-card { grid-template-columns: 1fr; }
}
/* ===============================
RECORDER
=============================== */
.rec-timer {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 28px;
font-weight: 800;
color: var(--rec-red-hover);
letter-spacing: 1px;
margin-left: auto;
min-width: 130px;
text-align: right;
}
.rec-status {
color: var(--text-2);
font-size: 12.5px;
font-weight: 600;
margin-left: 18px;
}
.rec-result {
color: var(--text);
font-size: 13px;
font-weight: 600;
margin-top: 6px;
word-break: break-all;
user-select: text;
}
.rec-result .badge-dur {
background: rgba(239, 68, 68, 0.15);
color: var(--rec-red-hover);
padding: 2px 10px;
border-radius: 10px;
font-size: 11.5px;
font-weight: 700;
margin-right: 10px;
}
/* ===============================
GUIDE
=============================== */
.guide-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 32px;
align-items: start;
}
.guide-toc {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 10px;
background: var(--bg-card);
border-radius: var(--r-md);
max-height: calc(100vh - 80px);
overflow-y: auto;
}
.toc-link {
color: var(--text-2);
text-decoration: none;
padding: 8px 12px;
border-radius: var(--r-sm);
font-size: 13px;
font-weight: 600;
transition: background-color 0.15s, color 0.15s;
}
.toc-link:hover {
background: var(--bg-input);
color: var(--text);
}
.toc-link.active {
background: var(--indigo-dim);
color: var(--indigo-hover);
}
.guide-content {
min-width: 0; /* evita overflow horizontal */
color: var(--text-2);
font-size: 14px;
line-height: 1.65;
}
.g-section {
background: var(--bg-card);
border-radius: var(--r-md);
padding: 24px 28px;
margin-bottom: 18px;
scroll-margin-top: 12px;
}
.g-section h2 {
font-size: 22px;
font-weight: 800;
color: var(--text);
margin-bottom: 10px;
letter-spacing: -0.3px;
}
.g-section h3 {
font-size: 15px;
font-weight: 800;
color: var(--text);
margin-top: 22px;
margin-bottom: 8px;
letter-spacing: 0.2px;
}
.g-section h4 {
font-size: 13px;
font-weight: 700;
color: var(--indigo-hover);
margin-top: 16px;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.6px;
}
.g-section p {
margin-bottom: 10px;
}
.g-section ul,
.g-section ol {
padding-left: 22px;
margin-bottom: 12px;
}
.g-section li {
margin-bottom: 6px;
}
.g-section strong { color: var(--text); }
.g-section code {
background: var(--bg-input);
padding: 2px 7px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px;
color: var(--green-hover);
}
.g-section pre {
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 12px 16px;
margin: 10px 0 14px;
overflow-x: auto;
}
.g-section pre code {
background: transparent;
padding: 0;
color: var(--text);
font-size: 13px;
}
.g-section a {
color: var(--indigo-hover);
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid transparent;
}
.g-section a:hover {
border-bottom-color: var(--indigo-hover);
}
.kbd {
background: var(--bg-input);
border: 1px solid var(--bg-input-hover);
color: var(--text);
padding: 1px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.g-box {
padding: 14px 18px;
border-radius: var(--r-sm);
margin: 14px 0;
font-size: 13.5px;
line-height: 1.55;
}
.g-box.info {
background: rgba(99, 102, 241, 0.10);
border-left: 3px solid var(--indigo);
}
.g-box.success {
background: rgba(29, 185, 84, 0.10);
border-left: 3px solid var(--green);
}
.g-box.warn {
background: rgba(253, 203, 110, 0.10);
border-left: 3px solid #FDCB6E;
}
.g-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
.g-table th,
.g-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--divider);
vertical-align: top;
}
.g-table th {
color: var(--text);
font-weight: 700;
font-size: 11.5px;
letter-spacing: 0.5px;
text-transform: uppercase;
background: var(--bg-card-2);
}
.g-table tr:last-child td {
border-bottom: none;
}
@media (max-width: 900px) {
.guide-layout { grid-template-columns: 1fr; }
.guide-toc {
position: static;
flex-direction: row;
flex-wrap: wrap;
max-height: none;
}
}
/* ============================================================
SCHERMATA ATTIVAZIONE LICENZA
============================================================ */
.activate-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
background:
radial-gradient(ellipse at top, rgba(29, 185, 84, 0.18), transparent 60%),
linear-gradient(180deg, #0a0a0a 0%, #060606 100%);
overflow: auto;
}
.activate-screen[hidden] { display: none; }
.activate-card {
width: 100%;
max-width: 460px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 40px 36px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
text-align: center;
}
.activate-logo {
width: 72px;
height: 72px;
margin: 0 auto 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 22px;
background: linear-gradient(135deg, #1db954 0%, #137a37 100%);
color: #fff;
font-size: 36px;
box-shadow: 0 12px 30px rgba(29, 185, 84, 0.4);
}
.activate-title {
margin: 0 0 8px;
font-size: 22px;
font-weight: 800;
color: var(--text);
}
.activate-sub {
margin: 0 0 28px;
font-size: 13.5px;
color: var(--text-muted);
line-height: 1.55;
}
.activate-form {
text-align: left;
}
.activate-form .field {
margin-bottom: 14px;
}
.activate-error {
background: rgba(220, 53, 69, 0.12);
border: 1px solid rgba(220, 53, 69, 0.4);
color: #ff6b7a;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
margin: 6px 0 14px;
}
.activate-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 6px;
}
.activate-actions .btn {
width: 100%;
justify-content: center;
}
.activate-foot {
margin-top: 24px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
.activate-foot a {
color: var(--accent);
text-decoration: none;
}
.activate-foot a:hover { text-decoration: underline; }
/* ============================================================
IMPOSTAZIONI > LICENZA
============================================================ */
.license-box {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.lic-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px dashed var(--divider);
font-size: 13.5px;
}
.lic-row:last-child { border-bottom: none; }
.lic-row span {
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.6px;
font-size: 11px;
font-weight: 600;
}
.lic-row strong {
color: var(--text);
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
}
.lic-warn {
padding: 14px;
border-radius: 12px;
background: rgba(220, 53, 69, 0.12);
border: 1px solid rgba(220, 53, 69, 0.35);
color: #ff6b7a;
font-weight: 700;
text-align: center;
}
.license-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* ============================================================
REGISTRA: card errore con log tecnici
============================================================ */
.rec-error-card {
border-left: 4px solid var(--red, #f87171);
}
.micro-label.red {
color: #ff8a92;
font-weight: 800;
}
.rec-error-msg {
margin-top: 6px;
font-size: 14px;
color: var(--text);
white-space: pre-line;
line-height: 1.55;
}
.rec-error-log {
margin-top: 12px;
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--text-2);
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
+575 -8
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MusicDownload</title> <title>MusicTools</title>
<link rel="stylesheet" href="css/style.css" /> <link rel="stylesheet" href="css/style.css" />
</head> </head>
<body> <body>
@@ -15,30 +15,55 @@
<div class="brand-logo"> <div class="brand-logo">
<span>♫</span> <span>♫</span>
</div> </div>
<div class="brand-name">MusicDownload</div> <div class="brand-name">MusicTools</div>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<nav class="nav"> <nav class="nav">
<button class="nav-item active" data-view="download"> <button class="nav-item active" data-view="download" data-feature="audio">
<span class="nav-icon">⬇</span> <span class="nav-icon">⬇</span>
<span>Scarica</span> <span>Scarica</span>
</button> </button>
<button class="nav-item" data-view="video"> <button class="nav-item" data-view="video" data-feature="video">
<span class="nav-icon">🎬</span> <span class="nav-icon">🎬</span>
<span>Video</span> <span>Video</span>
</button> </button>
<button class="nav-item" data-view="upgrade"> <button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span> <span class="nav-icon">⚡</span>
<span>Upgrade</span> <span>Upgrade</span>
</button> </button>
<button class="nav-item" data-view="meta" data-feature="metadata">
<span class="nav-icon">🏷</span>
<span>Metadati</span>
</button>
<button class="nav-item" data-view="record" data-feature="record">
<span class="nav-icon">●</span>
<span>Registra</span>
</button>
<button class="nav-item" data-view="settings"> <button class="nav-item" data-view="settings">
<span class="nav-icon">⚙</span> <span class="nav-icon">⚙</span>
<span>Impostazioni</span> <span>Impostazioni</span>
</button> </button>
<button class="nav-item" data-view="guide">
<span class="nav-icon">📖</span>
<span>Guida</span>
</button>
</nav> </nav>
<div class="quota-box" id="quotaBox" hidden>
<div class="quota-label">Oggi</div>
<div class="quota-value">
<span id="quotaUsed">0</span>
<span class="quota-sep">/</span>
<span id="quotaLimit">10</span>
</div>
<div class="quota-plan" id="quotaPlanName">Basic</div>
<button type="button" class="quota-upgrade" id="quotaUpgradeBtn" hidden>
Upgrade
</button>
</div>
<div class="footer"> <div class="footer">
<div class="footer-by">by LuZa</div> <div class="footer-by">by LuZa</div>
<div class="footer-version" id="footerVersion">v1.0</div> <div class="footer-version" id="footerVersion">v1.0</div>
@@ -63,15 +88,22 @@
<div class="card"> <div class="card">
<div class="field"> <div class="field">
<label class="field-label"> <label class="field-label">
URL URL o titolo brano
<span class="badge" id="urlListBadge" hidden></span> <span class="badge" id="urlListBadge" hidden></span>
</label> </label>
<div class="row"> <div class="row">
<input type="text" id="urlInput" class="input pill" placeholder="Incolla qui un link Spotify, YouTube o SoundCloud…" /> <input type="text" id="urlInput" class="input pill" placeholder="Link Spotify/YouTube… oppure Artista - Titolo · Titolo brano" />
<button class="btn btn-ghost pill" id="loadListBtn"> <button class="btn btn-ghost pill" id="loadListBtn">
<span>📂</span> Carica lista <span>📂</span> Carica lista
</button> </button>
</div> </div>
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
Puoi incollare un <strong>link</strong> (Spotify, YouTube, SoundCloud…) oppure scrivere direttamente
<strong>Artista - Titolo</strong> o solo il <strong>titolo del brano</strong>: l'app cerca il match migliore su YouTube e scarica in MP3.
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
@@ -136,6 +168,14 @@
</div> </div>
<div class="field inline"> <div class="field inline">
<label class="field-label">Modalità</label>
<div class="segmented" id="videoMode">
<button type="button" class="seg-btn active" data-mode="video">🎬 Video (MP4)</button>
<button type="button" class="seg-btn" data-mode="audio">🎵 Solo audio (MP3)</button>
</div>
</div>
<div class="field inline" id="videoQualityField">
<label class="field-label">Qualità</label> <label class="field-label">Qualità</label>
<select id="videoQuality" class="input pill input-sm"> <select id="videoQuality" class="input pill input-sm">
<option value="best">Migliore disponibile</option> <option value="best">Migliore disponibile</option>
@@ -147,7 +187,10 @@
<div class="hint"> <div class="hint">
<span class="hint-ico">ⓘ</span> <span class="hint-ico">ⓘ</span>
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi). <div>
In modalità <strong>Solo audio</strong> il file viene salvato in MP3 al bitrate impostato in Impostazioni → Download.<br>
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi).
</div>
</div> </div>
</div> </div>
@@ -237,6 +280,188 @@
</div> </div>
</section> </section>
<!-- ===== VIEW: REGISTRA ===== -->
<section class="view" id="view-record">
<header class="hero hero-red">
<div class="hero-content">
<div class="hero-eyebrow red">REGISTRAZIONE</div>
<h1 class="hero-title">Cattura audio</h1>
<p class="hero-subtitle">Registra in MP3 320 kbps da qualsiasi ingresso audio (mixer, scheda, microfono, BlackHole/loopback)</p>
</div>
<div class="hero-deco">●</div>
</header>
<h2 class="section-label">Dispositivo</h2>
<div class="card">
<div class="field">
<label class="field-label">Ingresso audio</label>
<div class="row">
<select id="recDevice" class="input pill">
<option value="">Caricamento…</option>
</select>
<button class="btn btn-ghost pill btn-sm" id="recRefreshBtn">↻ Aggiorna</button>
</div>
</div>
<div class="hint" id="recHint">
<span class="hint-ico">ⓘ</span>
<div>
<strong>Per registrare l'audio di sistema (Spotify/YouTube):</strong>
installa <code>brew install blackhole-2ch</code>, imposta l'uscita audio di macOS su <strong>BlackHole 2ch</strong>, poi seleziona qui sotto <strong>🔄 BlackHole 2ch (loopback)</strong>.
<br><br>
Per sentire l'audio mentre registri serve un <strong>Multi-Output Device</strong> in <em>Configurazione MIDI Audio</em> (BlackHole + altoparlanti) — guida completa nel
<a href="#" id="recOpenGuide">README su GitHub</a>.
</div>
</div>
</div>
<h2 class="section-label">Destinazione</h2>
<div class="card">
<div class="field">
<label class="field-label">Cartella output</label>
<div class="row">
<div class="path-display" id="recPathDisplay">Nessuna cartella selezionata</div>
<button class="btn btn-ghost pill btn-sm" id="recBrowseBtn">Sfoglia</button>
</div>
</div>
<div class="field">
<label class="field-label">Nome file (opzionale)</label>
<input type="text" id="recFilename" class="input pill" placeholder="Registrazione_AAAAMMGG_HHMMSS.mp3" />
</div>
<div class="field inline">
<label class="field-label">Bitrate</label>
<select id="recBitrate" class="input pill input-sm">
<option value="128k">128 kbps</option>
<option value="192k">192 kbps</option>
<option value="256k">256 kbps</option>
<option value="320k" selected>320 kbps</option>
</select>
</div>
</div>
<div class="card actions">
<button class="btn btn-red pill btn-lg" id="recStartBtn">
<span class="ico">●</span> REC
</button>
<button class="btn btn-danger pill btn-lg" id="recStopBtn" disabled>
<span class="ico">◼</span> Stop
</button>
<div class="rec-timer" id="recTimer">00:00:00</div>
<div class="rec-status" id="recStatus"></div>
</div>
<div class="card" id="recOutputCard" hidden>
<div class="micro-label">Ultima registrazione</div>
<div class="rec-result" id="recResult"></div>
</div>
<div class="card rec-error-card" id="recErrorCard" hidden>
<div class="micro-label red">Errore registrazione</div>
<div class="rec-error-msg" id="recErrorMsg"></div>
<div class="row" style="gap: 10px; margin-top: 12px;">
<button class="btn btn-ghost pill btn-sm" id="recShowLogBtn">Mostra log tecnici</button>
<button class="btn btn-ghost pill btn-sm" id="recCopyLogBtn" hidden>Copia log</button>
</div>
<pre class="rec-error-log" id="recErrorLog" hidden></pre>
</div>
</section>
<!-- ===== VIEW: METADATI ===== -->
<section class="view" id="view-meta">
<header class="hero hero-amber">
<div class="hero-content">
<div class="hero-eyebrow amber">METADATI</div>
<h1 class="hero-title">Modifica tag audio</h1>
<p class="hero-subtitle">Titolo · Artista · Album · Anno · Copertina · BPM · Key · MP3/M4A/FLAC</p>
</div>
<div class="hero-deco">🏷</div>
</header>
<h2 class="section-label">File</h2>
<div class="card">
<div class="row">
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
</div>
<div class="meta-info" id="metaInfo" hidden></div>
</div>
<div id="metaForm" hidden>
<h2 class="section-label">Tag</h2>
<div class="card">
<div class="form-grid">
<div class="field full">
<label class="field-label">Titolo</label>
<input type="text" id="metaTitle" class="input pill">
</div>
<div class="field full">
<label class="field-label">Artista</label>
<input type="text" id="metaArtist" class="input pill">
</div>
<div class="field">
<label class="field-label">Album</label>
<input type="text" id="metaAlbum" class="input pill">
</div>
<div class="field">
<label class="field-label">Album Artist</label>
<input type="text" id="metaAlbumArtist" class="input pill">
</div>
<div class="field">
<label class="field-label">Anno</label>
<input type="text" id="metaYear" class="input pill">
</div>
<div class="field">
<label class="field-label">Traccia</label>
<input type="text" id="metaTrack" class="input pill" placeholder="1/12">
</div>
<div class="field">
<label class="field-label">Genere</label>
<input type="text" id="metaGenre" class="input pill">
</div>
<div class="field">
<label class="field-label">BPM</label>
<input type="text" id="metaBpm" class="input pill">
</div>
<div class="field">
<label class="field-label">Key</label>
<input type="text" id="metaKey" class="input pill">
</div>
<div class="field full">
<label class="field-label">Commento</label>
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
</div>
<div class="field full" id="metaWhereFromField" hidden>
<label class="field-label">
Origine (macOS · kMDItemWhereFroms)
</label>
<textarea id="metaWhereFrom" class="input textarea" rows="2" placeholder="https://... (uno per riga)"></textarea>
<div class="cover-hint">Extended attribute macOS — una URL per riga. Vuoto = rimuove l'attributo.</div>
</div>
</div>
</div>
<h2 class="section-label">Copertina</h2>
<div class="card cover-card">
<div class="cover-preview" id="metaCoverPreview">
<span class="cover-empty">Nessuna copertina</span>
</div>
<div class="cover-actions">
<button class="btn btn-ghost pill" id="metaCoverBtn">📂 Cambia copertina</button>
<button class="btn btn-ghost pill" id="metaCoverRemoveBtn">✕ Rimuovi</button>
<div class="cover-hint" id="metaCoverHint"></div>
</div>
</div>
<div class="card actions">
<button class="btn btn-amber pill btn-lg" id="metaSaveBtn">💾 Salva tag</button>
<button class="btn btn-ghost pill btn-lg" id="metaReloadBtn">↺ Ricarica</button>
<div class="counter" id="metaStatus"></div>
</div>
</div>
</section>
<!-- ===== VIEW: SETTINGS ===== --> <!-- ===== VIEW: SETTINGS ===== -->
<section class="view" id="view-settings"> <section class="view" id="view-settings">
<header class="hero hero-purple"> <header class="hero hero-purple">
@@ -318,6 +543,22 @@
</div> </div>
</div> </div>
<h2 class="section-label">Licenza</h2>
<div class="card">
<div class="license-box" id="licenseStatusBox"></div>
<div class="license-actions">
<button class="btn btn-ghost pill" id="revalidateLicenseBtn">Verifica ora</button>
<button class="btn btn-danger pill" id="deactivateLicenseBtn">Disattiva su questo device</button>
</div>
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
Disattivando liberi uno slot di attivazione, utile se sposti l'app su un altro Mac/PC.
Puoi riattivare in qualsiasi momento con la stessa email e chiave.
</div>
</div>
</div>
<h2 class="section-label">Aggiornamenti</h2> <h2 class="section-label">Aggiornamenti</h2>
<div class="card"> <div class="card">
<div class="row spread"> <div class="row spread">
@@ -337,9 +578,318 @@
</div> </div>
</section> </section>
<!-- ===== VIEW: GUIDA ===== -->
<section class="view" id="view-guide">
<header class="hero hero-indigo">
<div class="hero-content">
<div class="hero-eyebrow indigo">GUIDA</div>
<h1 class="hero-title">Manuale d'uso</h1>
<p class="hero-subtitle">Come usare MusicTools al meglio · setup · troubleshooting</p>
</div>
<div class="hero-deco">📖</div>
</header>
<div class="guide-layout">
<!-- TOC sticky -->
<nav class="guide-toc">
<div class="micro-label" style="margin-bottom: 8px;">Sommario</div>
<a href="#g-intro" class="toc-link">Introduzione</a>
<a href="#g-setup" class="toc-link">Setup iniziale</a>
<a href="#g-download" class="toc-link">Tab Scarica</a>
<a href="#g-video" class="toc-link">Tab Video</a>
<a href="#g-upgrade" class="toc-link">Tab Upgrade</a>
<a href="#g-meta" class="toc-link">Tab Metadati</a>
<a href="#g-record" class="toc-link">Tab Registra</a>
<a href="#g-settings" class="toc-link">Impostazioni</a>
<a href="#g-update" class="toc-link">Aggiornamenti</a>
<a href="#g-troubleshoot" class="toc-link">Troubleshooting</a>
</nav>
<!-- Content -->
<article class="guide-content">
<section id="g-intro" class="g-section">
<h2>Introduzione</h2>
<p><strong>MusicTools</strong> è una app desktop tutto-in-uno per chi lavora con la musica: scarica brani e video da decine di piattaforme, migliora la qualità dei file esistenti, modifica i tag/metadati e registra l'audio da qualsiasi ingresso del computer.</p>
<p>L'interfaccia è organizzata in 6 sezioni accessibili dalla barra laterale: <span class="kbd">⬇ Scarica</span>, <span class="kbd">🎬 Video</span>, <span class="kbd">⚡ Upgrade</span>, <span class="kbd">🏷 Metadati</span>, <span class="kbd">● Registra</span>, <span class="kbd">⚙ Impostazioni</span>.</p>
</section>
<section id="g-setup" class="g-section">
<h2>Setup iniziale</h2>
<p>Prima di usare l'app, conviene fare 3 cose nelle <strong>Impostazioni</strong>:</p>
<ol>
<li><strong>Cartella output predefinita</strong> — dove vengono salvati tutti i download.</li>
<li><strong>Credenziali Spotify</strong> (solo se vuoi scaricare da link Spotify) — vedi il bottone "Come ottenere le credenziali?" nella tab Impostazioni.</li>
<li><strong>cookies.txt</strong> (opzionale) — necessario solo per scaricare contenuti privati Instagram/Facebook o aggirare rate-limit YouTube. Esportalo dal browser con l'estensione "Get cookies.txt LOCALLY".</li>
</ol>
<p>Tutti i campi vengono salvati automaticamente quando premi <strong>Salva Impostazioni</strong>.</p>
</section>
<section id="g-download" class="g-section">
<h2>Tab ⬇ Scarica</h2>
<p>Scarica musica in MP3 al bitrate impostato in Impostazioni (default 320 kbps).</p>
<h3>1 · Da Spotify</h3>
<p>Incolla un link a una <strong>playlist, album o brano</strong> (es. <code>open.spotify.com/playlist/...</code>). L'app risolve i metadati via API Spotify e scarica i brani da YouTube. <strong>Richiede</strong> le credenziali API impostate.</p>
<h3>2 · Da YouTube, SoundCloud e altri</h3>
<p>Incolla qualsiasi URL supportato da yt-dlp (singolo video o playlist). Funziona anche con Bandcamp, Mixcloud, ecc.</p>
<h3>3 · Lista da file .txt</h3>
<p>Clicca <strong>📂 Carica lista</strong>. Il file può contenere:</p>
<ul>
<li><strong>Una URL per riga</strong> — l'app le scarica una dopo l'altra.</li>
<li><strong>Tracklist con titoli</strong> (es. Beatport Top 100): formato <code>1. Artista – Titolo (variant) (durata)</code>. L'app riconosce automaticamente il formato e cerca ogni brano su YouTube.</li>
</ul>
<p>Nel caso tracklist, viene creata automaticamente una <strong>sottocartella</strong> col nome del file (es. <code>Beatport Top 100.txt</code> → <code>Beatport Top 100/</code>).</p>
<h3>4 · Skip duplicati</h3>
<p>Se riesegui la stessa playlist, i brani già scaricati vengono saltati in automatico. Il tracking funziona in due modi:</p>
<ul>
<li>File <code>.downloaded_tracks</code> dentro la cartella output.</li>
<li>Scan dei file presenti, con match dei nomi token-per-token (ignora parole come "Official Video", "Lyrics", "HD"...).</li>
</ul>
</section>
<section id="g-video" class="g-section">
<h2>Tab 🎬 Video</h2>
<p>Scarica video da <strong>YouTube, TikTok, Instagram, Facebook</strong> e oltre 1000 altri siti supportati da yt-dlp.</p>
<h3>Modalità</h3>
<ul>
<li><strong>🎬 Video (MP4)</strong> — output video+audio in MP4, qualità a scelta (best / 1080p / 720p / 480p).</li>
<li><strong>🎵 Solo audio (MP3)</strong> — estrae solo l'audio in MP3 al bitrate impostato in Impostazioni, con thumbnail embedded come copertina.</li>
</ul>
<h3>Contenuti privati</h3>
<p>Per scaricare reel Instagram privati, post Facebook privati o aggirare rate-limit YouTube serve un file <strong><code>cookies.txt</code></strong> valido (formato Netscape). Esportalo dal browser e impostalo in <strong>Impostazioni → Percorsi</strong>.</p>
<div class="g-box info">
<strong>Esempio:</strong> per scaricare la <em>Storia</em> di un utente Instagram seguito, fai login al browser, esporta i cookies, riavvia l'app.
</div>
</section>
<section id="g-upgrade" class="g-section">
<h2>Tab ⚡ Upgrade qualità</h2>
<p>Scansiona una cartella e <strong>riscarica i file sotto la soglia kbps</strong> impostata, sostituendoli con la versione a qualità maggiore.</p>
<ol>
<li>Seleziona la cartella con i file audio.</li>
<li>Imposta la <strong>Soglia HQ (kbps)</strong> — i file con bitrate inferiore verranno riprocessati. Default 310 kbps.</li>
<li>(Opzionale) Spunta <strong>Includi sottocartelle</strong> per la scansione ricorsiva.</li>
<li>Premi <strong>Avvia Upgrade</strong>.</li>
</ol>
<p>Per ogni file l'app cerca la versione migliore su YouTube e la sostituisce mantenendo nome originale. I file già processati vengono saltati grazie al tracking <code>.upgraded_tracks</code>.</p>
</section>
<section id="g-meta" class="g-section">
<h2>Tab 🏷 Metadati</h2>
<p>Editor completo dei tag audio. Apri un file con <strong>📂 Apri file audio</strong>.</p>
<h3>Formati supportati</h3>
<ul>
<li><strong>MP3</strong> — ID3v2.3</li>
<li><strong>M4A / AAC / MP4</strong> — Apple atoms</li>
<li><strong>FLAC</strong> — Vorbis comments</li>
<li><strong>WAV</strong> — ID3 embedded + fallback al chunk RIFF LIST/INFO (per file taggati da ffmpeg/Audacity)</li>
</ul>
<h3>Campi editabili</h3>
<p>Titolo, artista, album, album artist, anno, traccia (formato <code>1/12</code>), genere, BPM, key musicale, commento, copertina (JPG/PNG), e su macOS anche <strong>kMDItemWhereFroms</strong> (l'extended attribute che traccia l'origine del file scaricato).</p>
<h3>Copertina</h3>
<p>Anteprima 180×180 della copertina corrente. <strong>Cambia copertina</strong> ne carica una nuova; <strong>Rimuovi</strong> la marca per la rimozione al prossimo salvataggio.</p>
<h3>Annulla modifiche</h3>
<p>Premi <strong>↺ Ricarica</strong> per rileggere i tag dal file e scartare le modifiche non salvate.</p>
</section>
<section id="g-record" class="g-section">
<h2>Tab ● Registra</h2>
<p>Cattura audio in MP3 da qualsiasi ingresso del computer: <strong>mixer, scheda audio esterna, microfono, driver loopback</strong> (BlackHole/Soundflower/VB-Cable). Bitrate selezionabile 128–320 kbps.</p>
<h3>Setup standard (mixer/microfono)</h3>
<ol>
<li>Dropdown <strong>Dispositivo</strong>: seleziona la sorgente.</li>
<li>Imposta una cartella output e (opzionale) un nome file.</li>
<li>Premi <strong>● REC</strong> → registrazione, timer in tempo reale.</li>
<li><strong>◼ Stop</strong> → file MP3 finalizzato e linkato sotto.</li>
</ol>
<h3>Registrare l'audio di sistema su macOS (Spotify, YouTube...)</h3>
<p>macOS non espone nativamente l'uscita audio. Serve un driver loopback gratuito, il più diffuso è <strong>BlackHole</strong>.</p>
<h4>Installazione</h4>
<p>Apri Terminale e lancia:</p>
<pre><code>brew install blackhole-2ch</code></pre>
<p>Se subito dopo l'install BlackHole non compare nel dropdown, riavvia il servizio audio:</p>
<pre><code>sudo killall coreaudiod</code></pre>
<p>(rilancia da solo in pochi secondi, nessun rischio)</p>
<h4>Permesso microfono</h4>
<p>macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation. Vai su <strong>Preferenze di Sistema → Privacy e sicurezza → Microfono</strong> e abilita Terminal/MusicTools. Se la voce non c'è, comparirà un popup la prima volta che premi REC.</p>
<h4>Setup A — minimo (non senti l'audio durante)</h4>
<ol>
<li>Click icona altoparlante nella barra menu → <strong>Output → BlackHole 2ch</strong></li>
<li>Fai partire Spotify (non sentirai nulla — l'audio va a BlackHole)</li>
<li>Tab Registra → dispositivo <strong>🔄 BlackHole 2ch (loopback)</strong> → REC</li>
</ol>
<h4>Setup B — completo (registri E senti l'audio)</h4>
<ol>
<li>Apri <strong>Configurazione MIDI Audio</strong> (<code>Applicazioni → Utility</code>)</li>
<li>In basso a sinistra: <strong>+ → Crea dispositivo aggregato a uscita multipla</strong></li>
<li>Spunta sia <strong>BlackHole 2ch</strong> sia i tuoi <strong>Altoparlanti integrati</strong></li>
<li>Imposta <strong>BlackHole 2ch come Master Device</strong> (menu in alto)</li>
<li>Abilita <strong>Drift Correction</strong> sulla riga degli altoparlanti</li>
<li>(Opzionale) Rinomina in <code>Speakers + BlackHole</code></li>
<li>Preferenze di Sistema → Audio → Uscita → seleziona <code>Speakers + BlackHole</code></li>
<li>Nella tab Registra: scegli <strong>🔄 BlackHole 2ch (loopback)</strong>, non il dispositivo aggregato</li>
</ol>
<div class="g-box success">
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
</div>
</section>
<section id="g-settings" class="g-section">
<h2>Impostazioni</h2>
<h3>Spotify API</h3>
<p>Le credenziali API Spotify sono <strong>gratuite</strong> e non richiedono Premium. Vai su <a href="#" data-ext="https://developer.spotify.com/dashboard">Spotify Developer Dashboard</a>, "Create app", compila i campi (Redirect URI <code>http://localhost:8888/callback</code>, seleziona "Web API"), e copia Client ID + Client Secret nei campi corrispondenti.</p>
<h3>Download</h3>
<ul>
<li><strong>Bitrate</strong> — qualità target per gli MP3 (128/192/256/320 kbps).</li>
<li><strong>Soglia HQ</strong> — soglia kbps per la tab Upgrade. I file sotto questa soglia vengono riscaricati.</li>
</ul>
<h3>Percorsi</h3>
<ul>
<li><strong>cookies.txt</strong> — per contenuti privati/protetti.</li>
<li><strong>Cartella output predefinita</strong> — viene proposta in tutte le tab.</li>
</ul>
</section>
<section id="g-update" class="g-section">
<h2>Aggiornamenti</h2>
<p>In <strong>Impostazioni → Aggiornamenti</strong> trovi il bottone <strong>Controlla aggiornamenti</strong>. L'app interroga la pagina release di GitHub e ti mostra se c'è una versione nuova.</p>
<p>Se è disponibile un aggiornamento, comparirà un secondo bottone <strong>Scarica aggiornamento</strong> che apre direttamente il browser sull'asset corretto (macOS o Windows) della release più recente.</p>
</section>
<section id="g-troubleshoot" class="g-section">
<h2>Troubleshooting</h2>
<table class="g-table">
<thead><tr><th>Sintomo</th><th>Causa probabile</th><th>Fix</th></tr></thead>
<tbody>
<tr>
<td>BlackHole non compare nel dropdown della tab Registra</td>
<td>CoreAudio non l'ha ancora caricato dopo l'install</td>
<td><code>sudo killall coreaudiod</code> + premi <strong>↻ Aggiorna</strong></td>
</tr>
<tr>
<td>"Permesso microfono mancante" o errore ffmpeg -6</td>
<td>macOS nega l'accesso al device</td>
<td>Privacy e sicurezza → Microfono → abilita Terminal/MusicTools</td>
</tr>
<tr>
<td>File MP3 della registrazione è muto</td>
<td>L'output di sistema non passa da BlackHole</td>
<td>Imposta output su BlackHole o Multi-Output Device (Setup A/B)</td>
</tr>
<tr>
<td>Spotify dice "Autenticazione fallita"</td>
<td>Client ID/Secret mancanti o errati</td>
<td>Impostazioni → Spotify API → ripeti la procedura</td>
</tr>
<tr>
<td>Instagram/Facebook non scarica</td>
<td>Contenuto privato senza cookies validi</td>
<td>Esporta cookies.txt dal browser dopo login</td>
</tr>
<tr>
<td>Brano scaricato è sbagliato (versione cover invece dell'originale)</td>
<td>Ricerca YouTube ha preso il primo risultato</td>
<td>Modifica la query nel file di tracklist (più dettagli artista/titolo)</td>
</tr>
<tr>
<td>Tag non vengono letti su un WAV</td>
<td>File usa solo chunk INFO non ID3 (raro)</td>
<td>L'app ora gestisce entrambi — controlla di avere v1.2.1+</td>
</tr>
<tr>
<td>Stop button non risponde durante il download</td>
<td>yt-dlp sta finalizzando il file</td>
<td>Attendi qualche secondo, il processo si chiude dopo l'I/O</td>
</tr>
</tbody>
</table>
<h3>Reset configurazione</h3>
<p>Se vuoi azzerare tutte le impostazioni, cancella il file <code>config.json</code>. Posizione:</p>
<ul>
<li><strong>macOS</strong>: <code>~/Library/Application Support/MusicTools/config.json</code></li>
<li><strong>Windows</strong>: <code>%APPDATA%\MusicTools\config.json</code></li>
</ul>
<p>Verrà ricreato con valori di default al prossimo avvio.</p>
</section>
<section class="g-section">
<h2>Risorse</h2>
<ul>
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload">Codice sorgente su GitHub</a></li>
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload/releases">Tutte le release</a></li>
<li><a href="#" data-ext="https://github.com/yt-dlp/yt-dlp/wiki/FAQ">FAQ di yt-dlp</a></li>
<li><a href="#" data-ext="https://existential.audio/blackhole/">BlackHole — driver loopback macOS</a></li>
</ul>
<p style="color: var(--text-3); font-size: 12px; margin-top: 20px;">MusicTools — by <strong style="color: var(--green);">LuZa</strong></p>
</section>
</article>
</div>
</section>
</main> </main>
</div> </div>
<!-- ============== SCHERMATA ATTIVAZIONE LICENZA ============== -->
<div class="activate-screen" id="activateScreen" hidden>
<div class="activate-card">
<div class="activate-logo">♫</div>
<h1 class="activate-title">Benvenuto in MusicTools</h1>
<p class="activate-sub">
Inserisci la tua licenza per iniziare. Se non ne hai ancora una,
acquistala dal nostro sito ed entra in pochi secondi.
</p>
<div class="activate-form">
<div class="field">
<label class="field-label">Email dell'acquisto</label>
<input type="email" id="actEmail" class="input pill" placeholder="tu@esempio.com" autocomplete="email" />
</div>
<div class="field">
<label class="field-label">Chiave di licenza</label>
<input type="text" id="actKey" class="input pill" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" />
</div>
<div class="activate-error" id="actError" hidden></div>
<div class="activate-actions">
<button class="btn btn-primary pill btn-lg" id="actActivateBtn">
<span class="ico">🔓</span> Attiva
</button>
<button class="btn btn-ghost pill btn-lg" id="actBuyBtn">
Compra una licenza
</button>
</div>
</div>
<div class="activate-foot">
Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>
</div>
</div>
</div>
<!-- ============== MODAL: Spotify Guide ============== --> <!-- ============== MODAL: Spotify Guide ============== -->
<div class="modal-backdrop" id="guideModal" hidden> <div class="modal-backdrop" id="guideModal" hidden>
<div class="modal"> <div class="modal">
@@ -355,6 +905,23 @@
</div> </div>
</div> </div>
<!-- ============== MODAL: Upgrade richiesto ============== -->
<div class="modal-backdrop" id="upgradeModal" hidden>
<div class="modal">
<div class="modal-head">
<h2 id="upgradeModalTitle">Passa a un piano superiore</h2>
<button class="modal-close" id="upgradeCloseBtn">×</button>
</div>
<div class="modal-body" id="upgradeModalBody">
<!-- popolato dinamicamente -->
</div>
<div class="modal-foot">
<button class="btn btn-primary pill" id="upgradeOpenBtn">Vedi i piani</button>
<button class="btn btn-ghost pill" id="upgradeCloseBtn2">Chiudi</button>
</div>
</div>
</div>
<!-- ============== TOAST ============== --> <!-- ============== TOAST ============== -->
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
+686 -6
View File
@@ -1,5 +1,5 @@
/* ====================================================== /* ======================================================
MusicDownload — Frontend (vanilla JS) MusicTools — Frontend (vanilla JS)
====================================================== */ ====================================================== */
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
@@ -7,6 +7,8 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const state = { const state = {
config: {}, config: {},
license: { licensed: false },
purchaseUrl: "https://musictools.djluza.com",
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count } loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
dlOutputDir: "", dlOutputDir: "",
upDir: "", upDir: "",
@@ -15,6 +17,8 @@ const state = {
downloading: false, downloading: false,
upgrading: false, upgrading: false,
videoDownloading: false, videoDownloading: false,
meta: { path: "", newCoverPath: "", removeCover: false },
rec: { devices: [], outputDir: "", recording: false, seconds: 0 },
}; };
// Wait for pywebview ready // Wait for pywebview ready
@@ -25,6 +29,230 @@ function waitApi() {
}); });
} }
// Riconosce se una stringa e un URL (incluso schema spotify:)
function looksLikeUrl(s) {
if (!s) return false;
if (/^(https?|spotify):/i.test(s)) return true;
if (s.includes("://")) return true;
// Domini comuni anche senza http (es. "youtube.com/...")
return /^(www\.)?(youtube\.com|youtu\.be|spotify\.com|soundcloud\.com|tiktok\.com|instagram\.com|facebook\.com|fb\.watch)\//i.test(s);
}
// Trasforma una query libera in {name, artist}.
// Formati riconosciuti: "Artista - Titolo" (anche con – o —) oppure solo "Titolo".
function parseQueryToTrack(q) {
const s = (q || "").trim();
const m = s.match(/^(.+?)\s+[-–—]\s+(.+)$/);
if (m) return { artist: m[1].trim(), name: m[2].trim() };
return { artist: "", name: s };
}
// ============================================================
// Licenza: gating della UI principale
// ============================================================
function applyLicenseGate() {
const lic = state.license || { licensed: false };
const screen = $("#activateScreen");
const appEl = document.querySelector(".app");
if (!lic.licensed) {
if (screen) screen.hidden = false;
if (appEl) appEl.style.display = "none";
} else {
if (screen) screen.hidden = true;
if (appEl) appEl.style.display = "";
}
}
function fmtDate(epoch) {
if (!epoch) return "—";
try {
return new Date(epoch * 1000).toLocaleDateString("it-IT", {
day: "2-digit", month: "short", year: "numeric"
});
} catch (_e) { return "—"; }
}
function renderLicenseStatus() {
const lic = state.license || {};
const box = $("#licenseStatusBox");
if (!box) return; // panel non ancora montato
if (!lic.licensed) {
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
return;
}
const plan = lic.plan || {};
const planLine = plan.code
? `<div class="lic-row"><span>Piano</span><strong>${plan.name || plan.code}</strong></div>`
: "";
const limitLine = plan.daily_limit
? `<div class="lic-row"><span>Limite giornaliero</span><strong>${plan.daily_limit}</strong></div>`
: (plan.code === "annual"
? `<div class="lic-row"><span>Limite giornaliero</span><strong>Illimitato</strong></div>`
: "");
const expiryLine = plan.expires_at
? `<div class="lic-row"><span>Scade il</span><strong>${fmtDate(plan.expires_at)}</strong></div>`
: (plan.period_end
? `<div class="lic-row"><span>Prossimo rinnovo</span><strong>${fmtDate(plan.period_end)}</strong></div>`
: "");
box.innerHTML = `
${planLine}
${limitLine}
${expiryLine}
<div class="lic-row"><span>Email</span><strong>${lic.email || "—"}</strong></div>
<div class="lic-row"><span>Chiave</span><strong>${lic.key || "—"}</strong></div>
<div class="lic-row"><span>Attivata il</span><strong>${fmtDate(lic.activated_at)}</strong></div>
<div class="lic-row"><span>Ultima verifica</span><strong>${fmtDate(lic.last_validated_at)}</strong></div>
`;
}
// ============================================================
// Piano e quota giornaliera
// ============================================================
function applyPlanGate() {
// Nasconde le tab non incluse nel piano corrente.
const lic = state.license || {};
const features = (lic.plan && lic.plan.features) || [];
$$(".nav-item[data-feature]").forEach((btn) => {
const f = btn.dataset.feature;
const allowed = features.includes(f);
btn.hidden = !allowed;
if (!allowed && btn.classList.contains("active")) {
// Fallback alla prima tab disponibile (audio o settings)
const fallback = $$(".nav-item[data-feature]:not([hidden])")[0]
|| $(".nav-item[data-view='settings']");
if (fallback) showView(fallback.dataset.view);
}
});
}
function renderQuotaBox(q) {
const box = $("#quotaBox");
if (!box) return;
const lic = state.license || {};
const plan = (q && q.plan) || lic.plan || {};
if (!plan.code || plan.code === "annual" || plan.daily_limit == null) {
// Annual / unlimited -> niente contatore
box.hidden = true;
return;
}
box.hidden = false;
$("#quotaUsed").textContent = String((q && q.used) ?? 0);
$("#quotaLimit").textContent = String(plan.daily_limit);
$("#quotaPlanName").textContent = plan.name || plan.code;
const upBtn = $("#quotaUpgradeBtn");
if (upBtn) upBtn.hidden = plan.code === "premium";
}
async function refreshQuota() {
const lic = state.license || {};
const plan = lic.plan || {};
if (!lic.licensed || !plan.code || plan.daily_limit == null) {
renderQuotaBox(null);
return;
}
try {
const res = await window.pywebview.api.get_quota_status();
if (res && res.ok) {
renderQuotaBox(res.quota);
}
} catch (_e) { /* offline: lascia stato precedente */ }
}
function showUpgradeModal(reason, payload) {
const modal = $("#upgradeModal");
if (!modal) return;
const title = $("#upgradeModalTitle");
const body = $("#upgradeModalBody");
if (reason === "feature_not_in_plan") {
title.textContent = "Funzione non inclusa nel piano";
body.innerHTML = `
<p>${payload.error || "Questa funzione non e' inclusa nel tuo piano."}</p>
<p class="modal-hint">Passa a un piano superiore per sbloccarla.</p>
`;
} else if (reason === "quota_exceeded") {
const q = payload.quota || {};
title.textContent = "Limite giornaliero raggiunto";
body.innerHTML = `
<p>${payload.error || "Hai raggiunto il limite giornaliero del tuo piano."}</p>
<p class="modal-hint">Usati oggi: <strong>${q.used ?? "?"} / ${q.limit ?? "?"}</strong>. Il contatore si resetta a mezzanotte (ora di Roma).</p>
`;
} else if (reason === "license_invalid") {
title.textContent = "Licenza non valida";
body.innerHTML = `<p>${payload.error || "La tua licenza non e' piu' valida."}</p>`;
} else if (reason === "offline") {
title.textContent = "Connessione assente";
body.innerHTML = `<p>${payload.error || "Impossibile contattare il server."}</p>`;
} else {
title.textContent = "Operazione bloccata";
body.innerHTML = `<p>${payload.error || "L'operazione non e' stata avviata."}</p>`;
}
modal.hidden = false;
}
function hideUpgradeModal() {
const modal = $("#upgradeModal");
if (modal) modal.hidden = true;
}
/** Gestisce il dict di errore restituito dai metodi start_*: se e' un
* gate failure (feature/quota/license/offline), mostra il modal e
* ritorna true (= caller deve interrompere il flow). */
function handleGateBlock(res) {
if (!res || res.ok !== false) return false;
const r = res.reason;
if (r === "feature_not_in_plan" || r === "quota_exceeded"
|| r === "license_invalid" || r === "offline") {
showUpgradeModal(r, res);
return true;
}
return false;
}
async function activateLicense() {
const email = $("#actEmail").value.trim();
const key = $("#actKey").value.trim();
const err = $("#actError");
err.hidden = true;
err.textContent = "";
if (!email || !key) {
err.textContent = "Inserisci email e chiave di licenza.";
err.hidden = false;
return;
}
const btn = $("#actActivateBtn");
btn.disabled = true;
const oldText = btn.innerHTML;
btn.innerHTML = "Attivazione in corso…";
try {
const res = await window.pywebview.api.activate_license({ email, key });
if (res.ok) {
state.license = res.license;
applyLicenseGate();
renderLicenseStatus();
applyPlanGate();
refreshQuota();
toast("Licenza attivata. Benvenuto!", "success");
} else {
err.textContent = res.error || "Errore di attivazione.";
err.hidden = false;
}
} finally {
btn.disabled = false;
btn.innerHTML = oldText;
}
}
async function deactivateLicense() {
if (!confirm("Disattivare la licenza su questo dispositivo?")) return;
const res = await window.pywebview.api.deactivate_license();
if (res.ok) {
state.license = res.license;
applyLicenseGate();
renderLicenseStatus();
toast("Licenza disattivata", "info");
}
}
// ============================================================ // ============================================================
// Bridge — eventi spinti dal backend // Bridge — eventi spinti dal backend
// ============================================================ // ============================================================
@@ -37,6 +265,7 @@ window.bridge = {
const bridgeHandlers = { const bridgeHandlers = {
"log": ({ view, msg }) => appendLog(view, msg), "log": ({ view, msg }) => appendLog(view, msg),
"quota:update": (q) => renderQuotaBox(q),
"download:progress": (p) => { "download:progress": (p) => {
if (typeof p.overall === "number") { if (typeof p.overall === "number") {
@@ -103,8 +332,64 @@ const bridgeHandlers = {
$("#videoDownloadBtn").disabled = false; $("#videoDownloadBtn").disabled = false;
$("#stopVideoBtn").disabled = true; $("#stopVideoBtn").disabled = true;
}, },
"recording:event": (p) => {
const status = p.status;
if (status === "started") {
state.rec.recording = true;
state.rec.seconds = 0;
$("#recStartBtn").disabled = true;
$("#recStartBtn").classList.add("recording");
$("#recStopBtn").disabled = false;
$("#recDevice").disabled = true;
$("#recRefreshBtn").disabled = true;
$("#recTimer").textContent = "00:00:00";
$("#recStatus").textContent = "● Registrazione in corso…";
$("#recErrorCard").hidden = true; // pulisci eventuale errore precedente
} else if (status === "tick") {
state.rec.seconds = p.seconds || 0;
$("#recTimer").textContent = formatHms(state.rec.seconds);
} else if (status === "stopped") {
state.rec.recording = false;
$("#recStartBtn").disabled = false;
$("#recStartBtn").classList.remove("recording");
$("#recStopBtn").disabled = true;
$("#recDevice").disabled = false;
$("#recRefreshBtn").disabled = false;
$("#recStatus").textContent = "";
$("#recOutputCard").hidden = false;
$("#recResult").innerHTML = `
<span class="badge-dur">${formatHms(p.seconds || 0)}</span>
${p.output_path || ""}
`;
toast("Registrazione salvata", "success");
} else if (status === "error") {
state.rec.recording = false;
$("#recStartBtn").disabled = false;
$("#recStartBtn").classList.remove("recording");
$("#recStopBtn").disabled = true;
$("#recDevice").disabled = false;
$("#recRefreshBtn").disabled = false;
$("#recStatus").textContent = "";
const msg = p.error || "Errore sconosciuto";
$("#recErrorMsg").textContent = msg;
$("#recErrorCard").hidden = false;
$("#recErrorLog").hidden = true;
$("#recErrorLog").textContent = "";
$("#recCopyLogBtn").hidden = true;
$("#recShowLogBtn").textContent = "Mostra log tecnici";
toast("Errore registrazione", "error");
}
},
}; };
function formatHms(sec) {
const h = Math.floor(sec / 3600).toString().padStart(2, "0");
const m = Math.floor((sec % 3600) / 60).toString().padStart(2, "0");
const s = Math.floor(sec % 60).toString().padStart(2, "0");
return `${h}:${m}:${s}`;
}
// ============================================================ // ============================================================
// Logging // Logging
// ============================================================ // ============================================================
@@ -155,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));
}); });
@@ -166,10 +456,17 @@ async function init() {
await waitApi(); await waitApi();
const data = await window.pywebview.api.get_init_data(); const data = await window.pywebview.api.get_init_data();
state.config = data.config || {}; state.config = data.config || {};
state.license = data.license || { licensed: false };
state.purchaseUrl = data.purchase_url || "https://musictools.djluza.com";
$("#footerVersion").textContent = data.version; $("#footerVersion").textContent = data.version;
$("#currentVersionLabel").textContent = data.version; $("#currentVersionLabel").textContent = data.version;
$("#guideBody").textContent = data.spotify_guide; $("#guideBody").textContent = data.spotify_guide;
applyLicenseGate();
renderLicenseStatus();
applyPlanGate();
refreshQuota();
// Populate Settings fields // Populate Settings fields
$("#clientIdInput").value = state.config.client_id || ""; $("#clientIdInput").value = state.config.client_id || "";
$("#clientSecretInput").value = state.config.client_secret || ""; $("#clientSecretInput").value = state.config.client_secret || "";
@@ -178,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 || "";
@@ -195,6 +493,34 @@ async function init() {
// Upgrade tab — default threshold // Upgrade tab — default threshold
$("#upThreshold").value = state.config.hq_threshold || 310; $("#upThreshold").value = state.config.hq_threshold || 310;
// Record tab — default output dir + carica dispositivi
state.rec.outputDir = state.config.output_dir || "";
if (state.rec.outputDir) {
$("#recPathDisplay").textContent = state.rec.outputDir;
$("#recPathDisplay").classList.remove("empty");
}
await refreshRecDevices();
}
async function refreshRecDevices() {
const res = await window.pywebview.api.list_audio_inputs();
const select = $("#recDevice");
select.innerHTML = "";
state.rec.devices = res.devices || [];
if (!state.rec.devices.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "Nessun dispositivo trovato";
select.appendChild(opt);
return;
}
for (const d of state.rec.devices) {
const opt = document.createElement("option");
opt.value = d.id;
opt.textContent = d.is_virtual ? `🔄 ${d.name} (loopback)` : d.name;
select.appendChild(opt);
}
} }
// ============================================================ // ============================================================
@@ -255,17 +581,32 @@ $("#downloadBtn").addEventListener("click", async () => {
res = await window.pywebview.api.start_tracks_download({ res = await window.pywebview.api.start_tracks_download({
tracks: state.loaded.tracks, tracks: state.loaded.tracks,
output_dir: state.dlOutputDir, output_dir: state.dlOutputDir,
subfolder: state.loaded.name || "",
}); });
} else { } else {
let urls = state.loaded && state.loaded.kind === "urls" ? state.loaded.urls : []; let urls = state.loaded && state.loaded.kind === "urls" ? state.loaded.urls : [];
if (!urls.length) { if (!urls.length) {
const single = $("#urlInput").value.trim(); const single = $("#urlInput").value.trim();
if (!single) { if (!single) {
toast("Inserisci un URL o carica una lista", "error"); toast("Inserisci un URL, un titolo o carica una lista", "error");
bridgeHandlers["download:done"](); bridgeHandlers["download:done"]();
return; return;
} }
urls = [single]; if (looksLikeUrl(single)) {
urls = [single];
} else {
const track = parseQueryToTrack(single);
res = await window.pywebview.api.start_tracks_download({
tracks: [track],
output_dir: state.dlOutputDir,
subfolder: "",
});
if (!res.ok) {
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["download:done"]();
}
return;
}
} }
res = await window.pywebview.api.start_download({ res = await window.pywebview.api.start_download({
urls, urls,
@@ -274,7 +615,7 @@ $("#downloadBtn").addEventListener("click", async () => {
} }
if (!res.ok) { if (!res.ok) {
toast(res.error || "Errore", "error"); if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["download:done"](); bridgeHandlers["download:done"]();
} }
}); });
@@ -296,6 +637,17 @@ $("#videoBrowseBtn").addEventListener("click", async () => {
} }
}); });
let videoMode = "video"; // "video" | "audio"
$$("#videoMode .seg-btn").forEach((b) => {
b.addEventListener("click", () => {
$$("#videoMode .seg-btn").forEach((x) => x.classList.remove("active"));
b.classList.add("active");
videoMode = b.dataset.mode;
$("#videoQualityField").style.display = videoMode === "audio" ? "none" : "";
});
});
$("#videoDownloadBtn").addEventListener("click", async () => { $("#videoDownloadBtn").addEventListener("click", async () => {
const url = $("#videoUrlInput").value.trim(); const url = $("#videoUrlInput").value.trim();
if (!url) { if (!url) {
@@ -317,9 +669,10 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
urls: [url], urls: [url],
output_dir: state.videoOutputDir, output_dir: state.videoOutputDir,
quality: $("#videoQuality").value, quality: $("#videoQuality").value,
audio_only: videoMode === "audio",
}); });
if (!res.ok) { if (!res.ok) {
toast(res.error || "Errore", "error"); if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["video:done"](); bridgeHandlers["video:done"]();
} }
}); });
@@ -380,7 +733,7 @@ $("#upgradeBtn").addEventListener("click", async () => {
threshold: parseInt($("#upThreshold").value, 10) || 310, threshold: parseInt($("#upThreshold").value, 10) || 310,
}); });
if (!res.ok) { if (!res.ok) {
toast(res.error || "Errore", "error"); if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["upgrade:done"](); bridgeHandlers["upgrade:done"]();
} }
}); });
@@ -390,6 +743,274 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
$("#stopUpgradeBtn").disabled = true; $("#stopUpgradeBtn").disabled = true;
}); });
// ============================================================
// RECORDER tab
// ============================================================
$("#recRefreshBtn").addEventListener("click", refreshRecDevices);
$("#recOpenGuide").addEventListener("click", (e) => {
e.preventDefault();
showView("guide");
setTimeout(() => {
const el = document.getElementById("g-record");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, 80);
});
// ============================================================
// GUIDE — link esterni + TOC active highlight
// ============================================================
document.querySelectorAll("#view-guide a[data-ext]").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
window.pywebview.api.open_external_url(a.dataset.ext);
});
});
document.querySelectorAll(".toc-link").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
const id = a.getAttribute("href").slice(1);
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
// Aggiorna TOC active in base allo scroll della view
function setupGuideObserver() {
const sections = document.querySelectorAll(".g-section[id]");
const tocLinks = document.querySelectorAll(".toc-link");
if (!sections.length || !tocLinks.length) return;
const main = document.querySelector(".main");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const id = entry.target.id;
tocLinks.forEach((l) => {
l.classList.toggle("active", l.getAttribute("href") === "#" + id);
});
});
},
{ root: main, rootMargin: "-20% 0px -65% 0px", threshold: 0 }
);
sections.forEach((s) => observer.observe(s));
}
setupGuideObserver();
$("#recBrowseBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.browse_directory();
if (path) {
state.rec.outputDir = path;
$("#recPathDisplay").textContent = path;
$("#recPathDisplay").classList.remove("empty");
}
});
$("#recStartBtn").addEventListener("click", async () => {
const deviceId = $("#recDevice").value;
if (!deviceId) {
toast("Seleziona un dispositivo", "error");
return;
}
if (!state.rec.outputDir) {
toast("Seleziona una cartella di destinazione", "error");
return;
}
const res = await window.pywebview.api.start_audio_recording({
device_id: deviceId,
output_dir: state.rec.outputDir,
filename: $("#recFilename").value.trim(),
bitrate: $("#recBitrate").value,
});
if (!res.ok) {
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
}
});
$("#recStopBtn").addEventListener("click", async () => {
$("#recStopBtn").disabled = true;
await window.pywebview.api.stop_audio_recording();
});
$("#recShowLogBtn")?.addEventListener("click", async () => {
const pre = $("#recErrorLog");
if (!pre.hidden) {
pre.hidden = true;
$("#recShowLogBtn").textContent = "Mostra log tecnici";
$("#recCopyLogBtn").hidden = true;
return;
}
const res = await window.pywebview.api.get_recorder_log();
const lines = (res && res.lines) || [];
pre.textContent = lines.length ? lines.join("\n") : "(log vuoto)";
pre.hidden = false;
$("#recShowLogBtn").textContent = "Nascondi log tecnici";
$("#recCopyLogBtn").hidden = false;
});
$("#recCopyLogBtn")?.addEventListener("click", async () => {
const text = $("#recErrorLog").textContent || "";
try {
await navigator.clipboard.writeText(text);
toast("Log copiato negli appunti", "success");
} catch {
toast("Impossibile copiare", "error");
}
});
// ============================================================
// METADATA editor
// ============================================================
function formatDuration(sec) {
if (!sec) return "—";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function populateMetaForm(d) {
$("#metaTitle").value = d.title || "";
$("#metaArtist").value = d.artist || "";
$("#metaAlbum").value = d.album || "";
$("#metaAlbumArtist").value = d.album_artist || "";
$("#metaYear").value = d.year || "";
$("#metaTrack").value = d.track || "";
$("#metaGenre").value = d.genre || "";
$("#metaBpm").value = d.bpm || "";
$("#metaKey").value = d.key || "";
$("#metaComment").value = d.comment || "";
// WhereFroms (macOS only)
if (d.is_macos) {
$("#metaWhereFromField").hidden = false;
$("#metaWhereFrom").value = (d.where_from || []).join("\n");
} else {
$("#metaWhereFromField").hidden = true;
$("#metaWhereFrom").value = "";
}
// Info riga sotto il path
const info = $("#metaInfo");
info.hidden = false;
info.innerHTML = `
<span><strong>Formato:</strong>${d.format || "—"}</span>
<span><strong>Bitrate:</strong>${d.bitrate ? d.bitrate + " kbps" : "—"}</span>
<span><strong>Durata:</strong>${formatDuration(d.duration)}</span>
`;
// Cover
const preview = $("#metaCoverPreview");
preview.innerHTML = "";
if (d.cover_base64) {
const img = document.createElement("img");
img.src = `data:${d.cover_mime || "image/jpeg"};base64,${d.cover_base64}`;
preview.appendChild(img);
} else {
const span = document.createElement("span");
span.className = "cover-empty";
span.textContent = "Nessuna copertina";
preview.appendChild(span);
}
$("#metaCoverHint").textContent = "";
$("#metaCoverHint").className = "cover-hint";
state.meta.newCoverPath = "";
state.meta.removeCover = false;
}
async function loadMetaFile(path) {
const res = await window.pywebview.api.read_metadata(path);
if (!res.ok) {
toast("Errore lettura: " + (res.error || ""), "error");
return;
}
state.meta.path = path;
$("#metaPathDisplay").textContent = path;
$("#metaPathDisplay").classList.remove("empty");
$("#metaForm").hidden = false;
populateMetaForm(res.data);
}
$("#metaPickBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.pick_audio_file();
if (path) await loadMetaFile(path);
});
$("#metaCoverBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.pick_image_file();
if (!path) return;
state.meta.newCoverPath = path;
state.meta.removeCover = false;
// Mostra anteprima leggendo il file via fetch (file:// non funziona da webview;
// usiamo solo il nome come hint, l'utente vedra il risultato dopo Save)
const hint = $("#metaCoverHint");
hint.textContent = "Nuova copertina: " + path.split("/").pop();
hint.className = "cover-hint set";
});
$("#metaCoverRemoveBtn").addEventListener("click", () => {
state.meta.newCoverPath = "";
state.meta.removeCover = true;
const preview = $("#metaCoverPreview");
preview.innerHTML = '<span class="cover-empty">Verra rimossa al salvataggio</span>';
const hint = $("#metaCoverHint");
hint.textContent = "Copertina marcata per rimozione";
hint.className = "cover-hint remove";
});
$("#metaReloadBtn").addEventListener("click", async () => {
if (state.meta.path) await loadMetaFile(state.meta.path);
});
$("#metaSaveBtn").addEventListener("click", async () => {
if (!state.meta.path) {
toast("Nessun file caricato", "error");
return;
}
$("#metaSaveBtn").disabled = true;
const status = $("#metaStatus");
status.textContent = "Salvataggio…";
const payload = {
path: state.meta.path,
data: {
title: $("#metaTitle").value,
artist: $("#metaArtist").value,
album: $("#metaAlbum").value,
album_artist: $("#metaAlbumArtist").value,
year: $("#metaYear").value,
track: $("#metaTrack").value,
genre: $("#metaGenre").value,
bpm: $("#metaBpm").value,
key: $("#metaKey").value,
comment: $("#metaComment").value,
where_from: $("#metaWhereFrom").value
.split("\n")
.map(s => s.trim())
.filter(Boolean),
},
cover_path: state.meta.newCoverPath,
remove_cover: state.meta.removeCover,
};
const res = await window.pywebview.api.save_metadata(payload);
$("#metaSaveBtn").disabled = false;
if (res.ok) {
status.textContent = "✓ Tag salvati";
toast("Tag salvati", "success");
// Ricarica per mostrare lo stato aggiornato (es. nuova cover)
await loadMetaFile(state.meta.path);
} else {
status.textContent = "";
if (!handleGateBlock(res)) toast("Errore: " + (res.error || ""), "error");
}
});
// ============================================================ // ============================================================
// SETTINGS tab // SETTINGS tab
// ============================================================ // ============================================================
@@ -412,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,
@@ -422,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;
@@ -490,6 +1116,60 @@ $("#checkUpdateBtn").addEventListener("click", async () => {
} }
}); });
// ============================================================
// Licenza: listener dello schermo di attivazione + impostazioni
// ============================================================
$("#actActivateBtn")?.addEventListener("click", activateLicense);
$("#actBuyBtn")?.addEventListener("click", async () => {
await window.pywebview.api.open_purchase_page();
});
$("#actKey")?.addEventListener("keydown", (e) => {
if (e.key === "Enter") activateLicense();
});
$("#deactivateLicenseBtn")?.addEventListener("click", deactivateLicense);
$("#revalidateLicenseBtn")?.addEventListener("click", async () => {
const btn = $("#revalidateLicenseBtn");
btn.disabled = true;
const old = btn.textContent;
btn.textContent = "Verifica in corso…";
try {
const res = await window.pywebview.api.revalidate_license();
if (res.ok) {
state.license = res.license;
renderLicenseStatus();
applyPlanGate();
refreshQuota();
if (res.license.licensed) {
toast("Licenza verificata", "success");
} else {
applyLicenseGate();
toast("Licenza non piu valida", "error");
}
}
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
// ============================================================
// Modal Upgrade
// ============================================================
$("#upgradeCloseBtn")?.addEventListener("click", hideUpgradeModal);
$("#upgradeCloseBtn2")?.addEventListener("click", hideUpgradeModal);
$("#upgradeModal")?.addEventListener("click", (e) => {
if (e.target === e.currentTarget) hideUpgradeModal();
});
$("#upgradeOpenBtn")?.addEventListener("click", async () => {
hideUpgradeModal();
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
});
$("#quotaUpgradeBtn")?.addEventListener("click", async () => {
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
});
// ============================================================ // ============================================================
// Boot // Boot
// ============================================================ // ============================================================