Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30be62af78 | ||
|
|
a82f6f420c | ||
|
|
61459099f9 | ||
|
|
bcd4f22b1e | ||
|
|
8272985459 | ||
|
|
f339197861 | ||
|
|
2d8290579b | ||
|
|
339dd4f3cc | ||
|
|
4e917fd9ef | ||
|
|
729f3cd8a0 | ||
|
|
4ebfbd472e | ||
|
|
5202b2c586 | ||
|
|
05a2a58ace | ||
|
|
8610f4d95c | ||
|
|
b47fdd1ab3 | ||
|
|
e56a975b77 | ||
|
|
52002f5184 | ||
|
|
878e024c13 | ||
|
|
07794052c8 | ||
|
|
850d3f4b6c | ||
|
|
6f5c32165a | ||
|
|
39c92f44f4 | ||
|
|
9f37b7eb94 | ||
|
|
5041d3e14c | ||
|
|
97a440819a | ||
|
|
1d7832e246 | ||
|
|
61879c42d3 | ||
|
|
246ff1b0e5 | ||
|
|
3dcbdfa489 | ||
|
|
946452ef03 | ||
|
|
d8423ec215 |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
name: Build MusicDownload
|
||||
name: Build MusicTools
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -36,14 +36,14 @@ jobs:
|
||||
- name: Package .app -> zip
|
||||
run: |
|
||||
cd dist
|
||||
ditto -c -k --sequesterRsrc --keepParent MusicDownload.app ../MusicDownload-macOS.zip
|
||||
ls -lh ../MusicDownload-macOS.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip
|
||||
ls -lh ../MusicTools-macOS.zip
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-macOS
|
||||
path: MusicDownload-macOS.zip
|
||||
name: MusicTools-macOS
|
||||
path: MusicTools-macOS.zip
|
||||
retention-days: 14
|
||||
|
||||
# =========================================================
|
||||
@@ -69,14 +69,14 @@ jobs:
|
||||
- name: Package -> zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path dist/MusicDownload/* -DestinationPath MusicDownload-Windows.zip -CompressionLevel Optimal
|
||||
Get-Item MusicDownload-Windows.zip
|
||||
Compress-Archive -Path dist/MusicTools/* -DestinationPath MusicTools-Windows.zip -CompressionLevel Optimal
|
||||
Get-Item MusicTools-Windows.zip
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-Windows
|
||||
path: MusicDownload-Windows.zip
|
||||
name: MusicTools-Windows
|
||||
path: MusicTools-Windows.zip
|
||||
retention-days: 14
|
||||
|
||||
# =========================================================
|
||||
@@ -90,22 +90,22 @@ jobs:
|
||||
- name: Download macOS artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-macOS
|
||||
name: MusicTools-macOS
|
||||
path: artifacts/macos
|
||||
|
||||
- name: Download Windows artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-Windows
|
||||
name: MusicTools-Windows
|
||||
path: artifacts/windows
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: MusicDownload ${{ github.ref_name }}
|
||||
name: MusicTools ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
artifacts/macos/MusicDownload-macOS.zip
|
||||
artifacts/windows/MusicDownload-Windows.zip
|
||||
artifacts/macos/MusicTools-macOS.zip
|
||||
artifacts/windows/MusicTools-Windows.zip
|
||||
@@ -34,3 +34,18 @@ MUSICA/
|
||||
# Claude
|
||||
.claude/
|
||||
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/
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -8,14 +8,26 @@ Sviluppata da **LuZa**.
|
||||
|
||||
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à
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Aggiornamento automatico** — Verifica disponibilità nuove versioni dalla pagina Impostazioni.
|
||||
- **Lista multi-URL** — Carica un file `.txt` con un URL per riga per scaricare più playlist in sequenza.
|
||||
| Tab | Cosa fa |
|
||||
|---|---|
|
||||
| **⬇ Scarica** | Spotify (playlist/album/brano) · YouTube · SoundCloud · lista URL `.txt` · tracklist `.txt` con titoli (es. Beatport Top 100) |
|
||||
| **🎬 Video** | YouTube · TikTok · Instagram · Facebook (e 1000+ siti yt-dlp) in MP4, qualità best/1080p/720p/480p |
|
||||
| **⚡ Upgrade** | Scansiona una cartella audio e riscarica i file sotto la soglia kbps impostata |
|
||||
| **🏷 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
|
||||
|
||||
@@ -39,8 +51,9 @@ Dipendenze principali:
|
||||
| Pacchetto | Utilizzo |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `mutagen` | Lettura/scrittura tag ID3, MP4, FLAC, WAV |
|
||||
| `pyobjc-framework-WebKit` (macOS) | Backend WebKit 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**.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
MusicDownload/
|
||||
MusicTools/
|
||||
├── main.py # Entry point — bootstrap pywebview
|
||||
├── requirements.txt
|
||||
├── build_macos.py # Script build macOS (.app)
|
||||
@@ -75,14 +141,16 @@ MusicDownload/
|
||||
│ ├── config.py # Configurazione persistente (JSON)
|
||||
│ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg)
|
||||
│ ├── spotify_client.py # API Spotify (Client Credentials)
|
||||
│ ├── downloader.py # Download audio (playlist Spotify, URL diretti, video)
|
||||
│ └── upgrader.py # Upgrade qualità file esistenti
|
||||
│ ├── downloader.py # Download audio + video (Spotify, yt-dlp diretto)
|
||||
│ ├── upgrader.py # Upgrade qualità file esistenti
|
||||
│ ├── metadata.py # Lettura/scrittura tag ID3/MP4/Vorbis/WAV
|
||||
│ └── recorder.py # Registrazione audio via ffmpeg
|
||||
│
|
||||
├── api/
|
||||
│ └── bridge.py # Ponte JS <-> Python (pywebview.js_api)
|
||||
│
|
||||
└── 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
|
||||
└── js/app.js # Routing, eventi, chiamate API
|
||||
```
|
||||
@@ -103,8 +171,8 @@ Il file `config.json` viene creato automaticamente al primo avvio:
|
||||
|
||||
Posizione:
|
||||
- **Sviluppo**: directory del progetto
|
||||
- **macOS bundle**: `~/Library/Application Support/MusicDownload/`
|
||||
- **Windows bundle**: `%APPDATA%/MusicDownload/`
|
||||
- **macOS bundle**: `~/Library/Application Support/MusicTools/`
|
||||
- **Windows bundle**: `%APPDATA%/MusicTools/`
|
||||
|
||||
## Build locale
|
||||
|
||||
@@ -116,7 +184,7 @@ pip install pyinstaller
|
||||
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
|
||||
|
||||
@@ -125,44 +193,36 @@ pip install pyinstaller
|
||||
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)
|
||||
|
||||
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`)
|
||||
2. **`build-windows`** (runner `windows-latest`) → `MusicDownload-Windows.zip`
|
||||
1. **`build-macos`** (runner `macos-latest`) → `MusicTools-macOS.zip` (la `.app` zippata con `ditto`)
|
||||
2. **`build-windows`** (runner `windows-latest`) → `MusicTools-Windows.zip`
|
||||
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
|
||||
|
||||
Per pubblicare una nuova versione:
|
||||
|
||||
```bash
|
||||
git tag v1.2.0
|
||||
git push origin v1.2.0
|
||||
git tag v1.3.0
|
||||
git push origin v1.3.0
|
||||
```
|
||||
|
||||
Per fare solo una build di test (senza release):
|
||||
|
||||
```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>.
|
||||
|
||||
## Aggiornamento app
|
||||
|
||||
L'app verifica nuove versioni interrogando un `version.json` remoto. URL configurato in `api/bridge.py` (`UPDATE_URL` nel metodo `check_update`):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v1.2.0",
|
||||
"download_url": "https://github.com/luzadev/musicdownload/releases/latest",
|
||||
"notes": "Descrizione delle novità"
|
||||
}
|
||||
```
|
||||
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.
|
||||
|
||||
## Licenza
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -10,7 +10,8 @@ import time
|
||||
import webbrowser
|
||||
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 (
|
||||
download_playlist,
|
||||
download_direct_url,
|
||||
@@ -19,7 +20,20 @@ from core.downloader import (
|
||||
reset_stop as reset_download_stop,
|
||||
is_stopped as download_is_stopped,
|
||||
)
|
||||
from core.spotify_client import get_access_token, resolve_spotify_url
|
||||
from core.spotify_client import (
|
||||
get_access_token,
|
||||
resolve_spotify_url,
|
||||
resolve_spotify_track_no_auth,
|
||||
SpotifyAuthRequired,
|
||||
)
|
||||
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||
from core.recorder import (
|
||||
list_input_devices,
|
||||
start_recording,
|
||||
stop_recording as recorder_stop,
|
||||
is_recording,
|
||||
get_last_stderr as recorder_last_stderr,
|
||||
)
|
||||
from core.upgrader import (
|
||||
upgrade_folder,
|
||||
request_stop as request_upgrade_stop,
|
||||
@@ -31,7 +45,7 @@ SPOTIFY_GUIDE_TEXT = """\
|
||||
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
|
||||
2) Clicca "Create app".
|
||||
3) Compila:
|
||||
- App name: MusicDownload (o quello che vuoi)
|
||||
- App name: MusicTools (o quello che vuoi)
|
||||
- Description: a piacere
|
||||
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
|
||||
- Seleziona "Web API"
|
||||
@@ -106,10 +120,16 @@ class Api:
|
||||
def get_init_data(self) -> dict:
|
||||
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
|
||||
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 {
|
||||
"version": VERSION,
|
||||
"config": cfg,
|
||||
"config": safe_cfg,
|
||||
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
||||
"license": license_status,
|
||||
"plan": license_mod.get_plan(cfg),
|
||||
"purchase_url": "https://musictools.djluza.com",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -121,7 +141,10 @@ class Api:
|
||||
except (TypeError, ValueError):
|
||||
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_secret": (payload.get("client_secret") or "").strip(),
|
||||
"bitrate": payload.get("bitrate", "320K"),
|
||||
@@ -129,28 +152,144 @@ class Api:
|
||||
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
||||
"output_dir": (payload.get("output_dir") or "").strip(),
|
||||
"theme": payload.get("theme", "dark"),
|
||||
}
|
||||
})
|
||||
save_config(config)
|
||||
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
|
||||
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:
|
||||
"""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
|
||||
|
||||
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}¤t={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:
|
||||
req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "MusicDownload"})
|
||||
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", "")
|
||||
notes = data.get("notes", "") or ""
|
||||
download_url = data.get("download_url", "") or ""
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"current": VERSION,
|
||||
"remote": remote,
|
||||
"is_new": bool(remote and remote != VERSION),
|
||||
"download_url": data.get("download_url", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
"download_url": download_url,
|
||||
"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:
|
||||
return {"ok": False, "error": str(e), "current": VERSION}
|
||||
|
||||
@@ -210,13 +349,110 @@ class Api:
|
||||
t = _parse_track_line(line)
|
||||
if 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:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def open_external_url(self, url: str) -> None:
|
||||
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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -247,6 +483,10 @@ class Api:
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
gate = self._gate("audio")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._download_worker,
|
||||
args=(list(urls), output_dir),
|
||||
@@ -257,17 +497,29 @@ class Api:
|
||||
|
||||
def start_tracks_download(self, payload: dict) -> dict:
|
||||
"""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():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
tracks = payload.get("tracks") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
subfolder = (payload.get("subfolder") or "").strip()
|
||||
if not tracks:
|
||||
return {"ok": False, "error": "Nessuna traccia fornita"}
|
||||
if not output_dir:
|
||||
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(
|
||||
target=self._tracks_worker,
|
||||
args=(list(tracks), output_dir),
|
||||
@@ -289,6 +541,7 @@ class Api:
|
||||
view = "download"
|
||||
|
||||
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
|
||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.10
|
||||
@@ -342,14 +595,11 @@ class Api:
|
||||
view = "download"
|
||||
|
||||
token = None
|
||||
client_id = cfg.get("client_id", "")
|
||||
client_secret = cfg.get("client_secret", "")
|
||||
has_credentials = bool(client_id and client_secret)
|
||||
has_spotify = any("spotify.com" in u for u in urls)
|
||||
if has_spotify:
|
||||
client_id = cfg.get("client_id", "")
|
||||
client_secret = cfg.get("client_secret", "")
|
||||
if not client_id or not client_secret:
|
||||
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
|
||||
self._emit("download:done", {"ok": False})
|
||||
return
|
||||
if has_spotify and has_credentials:
|
||||
self._log(view, "[INFO] Autenticazione Spotify...")
|
||||
try:
|
||||
token = get_access_token(client_id, client_secret)
|
||||
@@ -357,6 +607,13 @@ class Api:
|
||||
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
|
||||
self._emit("download:done", {"ok": False})
|
||||
return
|
||||
elif has_spotify:
|
||||
self._log(
|
||||
view,
|
||||
"[INFO] Spotify API non configurate: i singoli brani "
|
||||
"verranno comunque scaricati via metadata pubblico. "
|
||||
"Per album/playlist servono le credenziali (gratuite, 2 min)."
|
||||
)
|
||||
|
||||
for url_idx, url in enumerate(urls):
|
||||
if download_is_stopped():
|
||||
@@ -416,12 +673,19 @@ class Api:
|
||||
if is_spotify:
|
||||
self._log(view, "[INFO] Recupero informazioni da Spotify...")
|
||||
try:
|
||||
label, name, tracks = resolve_spotify_url(token, url)
|
||||
if token:
|
||||
label, name, tracks = resolve_spotify_url(token, url)
|
||||
else:
|
||||
# Senza API keys: fallback solo per singoli brani.
|
||||
label, name, tracks = resolve_spotify_track_no_auth(url)
|
||||
except SpotifyAuthRequired as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
|
||||
dest_dir = os.path.join(output_dir, name)
|
||||
dest_dir = os.path.join(output_dir, name) if len(tracks) > 1 else output_dir
|
||||
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
|
||||
else:
|
||||
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
|
||||
@@ -453,6 +717,10 @@ class Api:
|
||||
if not directory:
|
||||
return {"ok": False, "error": "Cartella non impostata"}
|
||||
|
||||
gate = self._gate("upgrade")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
cfg = load_config()
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
|
||||
@@ -519,15 +787,21 @@ class Api:
|
||||
urls = payload.get("urls") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
quality = payload.get("quality") or "1080p"
|
||||
audio_only = bool(payload.get("audio_only", False))
|
||||
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessun URL fornito"}
|
||||
if not output_dir:
|
||||
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(
|
||||
target=self._video_worker,
|
||||
args=(list(urls), output_dir, quality),
|
||||
args=(list(urls), output_dir, quality, audio_only),
|
||||
daemon=True,
|
||||
)
|
||||
self._video_thread.start()
|
||||
@@ -538,10 +812,12 @@ class Api:
|
||||
self._log("video", "[INFO] Interruzione richiesta...")
|
||||
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()
|
||||
cfg = load_config()
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
total_urls = len(urls)
|
||||
multi = total_urls > 1
|
||||
view = "video"
|
||||
@@ -597,9 +873,11 @@ class Api:
|
||||
|
||||
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:
|
||||
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:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build script per creare MusicDownload.app su macOS.
|
||||
Build script per creare MusicTools.app su macOS.
|
||||
|
||||
Uso:
|
||||
python3 build_macos.py
|
||||
@@ -8,7 +8,7 @@ Uso:
|
||||
Cosa fa:
|
||||
1. Scarica il binario standalone di yt-dlp per macOS
|
||||
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:
|
||||
pip3 install pyinstaller
|
||||
@@ -208,28 +208,34 @@ def run_pyinstaller():
|
||||
if f.is_file():
|
||||
add_binaries.extend(["--add-binary", f"{f}:lib"])
|
||||
|
||||
icon_path = ROOT / "assets" / "icon.icns"
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "MusicDownload",
|
||||
"--name", "MusicTools",
|
||||
"--windowed",
|
||||
"--onedir",
|
||||
"--noconfirm",
|
||||
]
|
||||
if icon_path.exists():
|
||||
cmd.extend(["--icon", str(icon_path)])
|
||||
cmd.extend([
|
||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||
"--add-data", f"{ROOT / 'webui'}:webui",
|
||||
# Hidden imports per pywebview (WebKit su macOS)
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.cocoa",
|
||||
"--hidden-import", "requests",
|
||||
"--collect-all", "mutagen",
|
||||
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
str(ROOT / "main.py"),
|
||||
]
|
||||
])
|
||||
|
||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||
|
||||
app_path = DIST_DIR / "MusicDownload.app"
|
||||
app_path = DIST_DIR / "MusicTools.app"
|
||||
if app_path.exists():
|
||||
log(f"Build completata: {app_path}")
|
||||
# Mostra dimensione
|
||||
@@ -237,8 +243,8 @@ def run_pyinstaller():
|
||||
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
||||
else:
|
||||
log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller")
|
||||
# Potrebbe essere in dist/MusicDownload/ (non .app)
|
||||
alt = DIST_DIR / "MusicDownload"
|
||||
# Potrebbe essere in dist/MusicTools/ (non .app)
|
||||
alt = DIST_DIR / "MusicTools"
|
||||
if alt.exists():
|
||||
log(f"Trovata directory: {alt}")
|
||||
|
||||
@@ -248,7 +254,7 @@ def run_pyinstaller():
|
||||
# =========================================================================
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" MusicDownload — Build macOS")
|
||||
print(" MusicTools — Build macOS")
|
||||
print("=" * 50)
|
||||
|
||||
# Verifica pyinstaller
|
||||
@@ -271,12 +277,12 @@ def main():
|
||||
print("\n" + "=" * 50)
|
||||
print(" DONE!")
|
||||
print("=" * 50)
|
||||
app = DIST_DIR / "MusicDownload.app"
|
||||
app = DIST_DIR / "MusicTools.app"
|
||||
if app.exists():
|
||||
print(f"\n L'app e in: {app}")
|
||||
print(f" Per testarla: open '{app}'")
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Build script per creare MusicDownload.exe su Windows.
|
||||
Build script per creare MusicTools.exe su Windows.
|
||||
|
||||
Uso:
|
||||
python build_windows.py
|
||||
@@ -7,7 +7,7 @@ Uso:
|
||||
Cosa fa:
|
||||
1. Scarica il binario standalone di yt-dlp per Windows
|
||||
2. Scarica ffmpeg static build per Windows
|
||||
3. Esegue PyInstaller per creare MusicDownload.exe
|
||||
3. Esegue PyInstaller per creare MusicTools.exe
|
||||
|
||||
Requisiti:
|
||||
pip install pyinstaller
|
||||
@@ -99,14 +99,17 @@ def run_pyinstaller():
|
||||
if f.is_file() and f.suffix in (".exe", ""):
|
||||
add_binaries.extend(["--add-binary", f"{f};."])
|
||||
|
||||
icon_path = ROOT / "assets" / "icon.ico"
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "MusicDownload",
|
||||
"--name", "MusicTools",
|
||||
"--windowed",
|
||||
"--onedir",
|
||||
"--noconfirm",
|
||||
# Icona (se presente)
|
||||
# "--icon", str(ROOT / "icon.ico"),
|
||||
]
|
||||
if icon_path.exists():
|
||||
cmd.extend(["--icon", str(icon_path)])
|
||||
cmd.extend([
|
||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||
"--add-data", f"{ROOT / 'webui'};webui",
|
||||
# Hidden imports per pywebview (EdgeChromium su Windows)
|
||||
@@ -116,20 +119,21 @@ def run_pyinstaller():
|
||||
"--hidden-import", "requests",
|
||||
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
|
||||
"--collect-all", "webview",
|
||||
"--collect-all", "mutagen",
|
||||
# Binari bundled
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
str(ROOT / "main.py"),
|
||||
]
|
||||
])
|
||||
|
||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||
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():
|
||||
log(f"Build completata: {exe_path}")
|
||||
else:
|
||||
log("ATTENZIONE: MusicDownload.exe non trovato, controlla l'output")
|
||||
log("ATTENZIONE: MusicTools.exe non trovato, controlla l'output")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -137,7 +141,7 @@ def run_pyinstaller():
|
||||
# =========================================================================
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" MusicDownload - Build Windows")
|
||||
print(" MusicTools - Build Windows")
|
||||
print("=" * 50)
|
||||
|
||||
if sys.platform != "win32":
|
||||
@@ -166,12 +170,12 @@ def main():
|
||||
print("\n" + "=" * 50)
|
||||
print(" DONE!")
|
||||
print("=" * 50)
|
||||
dist = DIST_DIR / "MusicDownload"
|
||||
dist = DIST_DIR / "MusicTools"
|
||||
if dist.exists():
|
||||
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" Comprimi {dist} -> MusicDownload_Windows.zip")
|
||||
print(f" Comprimi {dist} -> MusicTools_Windows.zip")
|
||||
print()
|
||||
|
||||
|
||||
|
||||
@@ -5,24 +5,53 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.0"
|
||||
VERSION = "v1.7.2"
|
||||
|
||||
|
||||
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:
|
||||
"""Ritorna la directory per config.json.
|
||||
|
||||
Frozen (bundle PyInstaller):
|
||||
- macOS: ~/Library/Application Support/MusicDownload/
|
||||
- Windows: %APPDATA%/MusicDownload/
|
||||
- macOS: ~/Library/Application Support/MusicTools/
|
||||
- Windows: %APPDATA%/MusicTools/
|
||||
Dev (non frozen):
|
||||
- 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 sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA", Path.home()))
|
||||
else:
|
||||
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)
|
||||
return config_dir
|
||||
return Path(__file__).resolve().parent.parent
|
||||
@@ -41,6 +70,21 @@ DEFAULTS = {
|
||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||
"output_dir": str(_project_dir / "MUSICA"),
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import threading
|
||||
from pathlib import Path
|
||||
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
|
||||
@@ -102,7 +102,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
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:
|
||||
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
|
||||
|
||||
@@ -146,8 +146,10 @@ def download_playlist(
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
query = f"{track['name']} - {track['artist']}"
|
||||
track_only = track['name']
|
||||
artist = (track.get('artist') or "").strip()
|
||||
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)
|
||||
if query in done_set:
|
||||
@@ -204,6 +206,7 @@ def download_playlist(
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
@@ -278,7 +281,7 @@ def download_direct_url(
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
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:
|
||||
raise ValueError(result.stderr.strip())
|
||||
except Exception as e:
|
||||
@@ -356,6 +359,7 @@ def download_direct_url(
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
@@ -418,10 +422,14 @@ def download_video(
|
||||
quality: str = "1080p",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
audio_only: bool = False,
|
||||
audio_bitrate: str = "320K",
|
||||
) -> 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.
|
||||
"""
|
||||
global _current_process
|
||||
@@ -439,7 +447,7 @@ def download_video(
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
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:
|
||||
raise ValueError(result.stderr.strip())
|
||||
except Exception as e:
|
||||
@@ -461,7 +469,7 @@ def download_video(
|
||||
return
|
||||
|
||||
total = len(entries)
|
||||
fmt = _video_format(quality)
|
||||
fmt = _video_format(quality) if not audio_only else "bestaudio/best"
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
if is_stopped():
|
||||
@@ -483,16 +491,31 @@ def download_video(
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "downloading", 0)
|
||||
|
||||
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,
|
||||
]
|
||||
if audio_only:
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"-f", fmt,
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", audio_bitrate.rstrip("Kk"),
|
||||
"--embed-thumbnail",
|
||||
"--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()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
@@ -503,6 +526,7 @@ def download_video(
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
|
||||
@@ -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
|
||||
@@ -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 [])
|
||||
@@ -48,6 +48,19 @@ def _exe(name: str) -> str:
|
||||
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:
|
||||
"""Trova il path di yt-dlp.
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Registrazione audio da un dispositivo di input via ffmpeg.
|
||||
|
||||
macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole)
|
||||
Windows: usa DirectShow
|
||||
"""
|
||||
|
||||
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)
|
||||
# ============================================================
|
||||
_current_proc: Optional[subprocess.Popen] = None
|
||||
_proc_lock = threading.Lock()
|
||||
_stop_event = threading.Event()
|
||||
_recording_thread: Optional[threading.Thread] = None
|
||||
_stderr_buf: list = []
|
||||
_stderr_lock = threading.Lock()
|
||||
|
||||
|
||||
def is_recording() -> bool:
|
||||
with _proc_lock:
|
||||
return _current_proc is not None and _current_proc.poll() is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}.
|
||||
|
||||
is_virtual = True per driver di loopback noti (BlackHole, Soundflower,
|
||||
Loopback, Audio Hijack, VB-Cable, ...)
|
||||
"""
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return []
|
||||
|
||||
if _IS_MAC:
|
||||
return _list_macos(ffmpeg)
|
||||
if _IS_WIN:
|
||||
return _list_windows(ffmpeg)
|
||||
return []
|
||||
|
||||
|
||||
_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)
|
||||
|
||||
|
||||
def _list_macos(ffmpeg: str) -> list[dict]:
|
||||
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
|
||||
L'output va su stderr."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
||||
"-list_devices", "true", "-i", ""],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
devices: list[dict] = []
|
||||
in_audio = False
|
||||
audio_section_re = re.compile(r"AVFoundation\s+audio\s+devices", re.IGNORECASE)
|
||||
other_section_re = re.compile(r"AVFoundation\s+(?:video)\s+devices", re.IGNORECASE)
|
||||
line_re = re.compile(r"\[(\d+)\]\s+(.+?)\s*$")
|
||||
|
||||
for raw in proc.stderr.splitlines():
|
||||
line = raw.strip()
|
||||
if audio_section_re.search(line):
|
||||
in_audio = True
|
||||
continue
|
||||
if other_section_re.search(line):
|
||||
in_audio = False
|
||||
continue
|
||||
if not in_audio:
|
||||
continue
|
||||
clean = re.sub(r"^\[AVFoundation[^\]]*\]\s*", "", line)
|
||||
m = line_re.match(clean)
|
||||
if not m:
|
||||
continue
|
||||
idx, name = m.group(1), m.group(2).strip()
|
||||
devices.append({"id": idx, "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)})
|
||||
|
||||
# Dedup preservando l'ordine
|
||||
seen = set()
|
||||
out = []
|
||||
for d in devices:
|
||||
if d["id"] in seen:
|
||||
continue
|
||||
seen.add(d["id"])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione
|
||||
# ============================================================
|
||||
def _build_record_cmd(ffmpeg: str, device_id: str, output_path: str,
|
||||
bitrate: str) -> list[str]:
|
||||
"""Comando ffmpeg per registrare su file MP3."""
|
||||
if _IS_MAC:
|
||||
return [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "avfoundation",
|
||||
"-i", f":{device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
output_path,
|
||||
]
|
||||
if _IS_WIN:
|
||||
return [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
output_path,
|
||||
]
|
||||
raise RuntimeError("Sistema operativo non supportato per la registrazione")
|
||||
|
||||
|
||||
def start_recording(
|
||||
device_id: str,
|
||||
output_path: str,
|
||||
bitrate: str = "320k",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
"""Avvia una registrazione in un thread.
|
||||
Ritorna {ok, error?, output_path?}.
|
||||
|
||||
progress_callback(status, payload) viene chiamato con:
|
||||
("started", {"output_path": ...})
|
||||
("tick", {"seconds": int})
|
||||
("stopped", {"output_path": ..., "seconds": int})
|
||||
("error", {"error": "..."})
|
||||
"""
|
||||
global _current_proc, _recording_thread
|
||||
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
|
||||
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 = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
|
||||
|
||||
_stop_event.clear()
|
||||
with _stderr_lock:
|
||||
_stderr_buf.clear()
|
||||
|
||||
def _drain_stderr(proc):
|
||||
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
|
||||
del pipe e per salvare le ultime righe ai fini diagnostici."""
|
||||
try:
|
||||
for raw in iter(proc.stderr.readline, b""):
|
||||
if not raw:
|
||||
break
|
||||
line = raw.decode("utf-8", errors="ignore").rstrip()
|
||||
with _stderr_lock:
|
||||
_stderr_buf.append(line)
|
||||
# Mantieni solo le ultime 50 righe per non sforare la RAM
|
||||
if len(_stderr_buf) > 50:
|
||||
del _stderr_buf[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker():
|
||||
global _current_proc
|
||||
try:
|
||||
with _proc_lock:
|
||||
_current_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
drain_t = threading.Thread(target=_drain_stderr,
|
||||
args=(_current_proc,), daemon=True)
|
||||
drain_t.start()
|
||||
|
||||
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
|
||||
# primi ~1.5s, segnala errore prima di emettere 'started'.
|
||||
start = time.monotonic()
|
||||
opened = False
|
||||
for _ in range(15):
|
||||
if _current_proc.poll() is not None:
|
||||
break
|
||||
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
|
||||
with _stderr_lock:
|
||||
if any("Press [q]" in l for l in _stderr_buf):
|
||||
opened = True
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
rc_early = _current_proc.poll()
|
||||
if not opened and rc_early is not None:
|
||||
# ffmpeg e' uscito prima di iniziare a registrare
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
|
||||
return
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out)})
|
||||
|
||||
while True:
|
||||
if _stop_event.is_set():
|
||||
break
|
||||
rc = _current_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)
|
||||
|
||||
# Stop gracefully
|
||||
if _current_proc.poll() is None:
|
||||
try:
|
||||
_current_proc.stdin.write(b"q\n")
|
||||
_current_proc.stdin.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_current_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.terminate()
|
||||
try:
|
||||
_current_proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.kill()
|
||||
|
||||
rc = _current_proc.returncode
|
||||
seconds = int(time.monotonic() - start)
|
||||
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
|
||||
if progress_callback:
|
||||
if rc == 0 or out.exists():
|
||||
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
||||
else:
|
||||
progress_callback("error", {"error": _extract_friendly_error(rc)})
|
||||
except Exception as e:
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": str(e)})
|
||||
|
||||
_recording_thread = threading.Thread(target=_worker, daemon=True)
|
||||
_recording_thread.start()
|
||||
|
||||
return {"ok": True, "output_path": str(out)}
|
||||
|
||||
|
||||
def _extract_friendly_error(rc: int) -> str:
|
||||
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
|
||||
Riconosce i casi tipici: permessi microfono, device occupato,
|
||||
device non disponibile, clock drift di BlackHole, ecc.
|
||||
|
||||
Fallback: se non riconosce il pattern, ritorna le ultime righe
|
||||
significative dello stderr cosi' l'utente puo' diagnosticare
|
||||
o aprire un ticket.
|
||||
"""
|
||||
with _stderr_lock:
|
||||
lines = list(_stderr_buf)
|
||||
|
||||
text = "\n".join(lines).lower()
|
||||
is_mac = sys.platform == "darwin"
|
||||
|
||||
# ---- Errori specifici (priorita' alta) ----
|
||||
|
||||
if "input/output error" in text or "errno 22" in text:
|
||||
return (
|
||||
"Dispositivo audio non pronto (BlackHole o scheda virtuale). "
|
||||
"Spesso si risolve cosi':\n"
|
||||
" 1) Smetti la registrazione e riprova fra 5 secondi\n"
|
||||
" 2) Se persiste, da Terminale: 'sudo killall coreaudiod'\n"
|
||||
" 3) Verifica che nessun'altra app stia gia' registrando il device"
|
||||
)
|
||||
|
||||
if "device not configured" in text or "device not available" in text:
|
||||
return "Dispositivo non configurato. Premi 'Aggiorna' e riseleziona."
|
||||
|
||||
if "permission" in text or "not authorized" in text or "not permitted" in text \
|
||||
or "tcc" in text or "denied" in text:
|
||||
return _permission_message()
|
||||
|
||||
if "no such device" in text or "no such audio device" in text or "invalid device" in text:
|
||||
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona dalla lista."
|
||||
|
||||
# ---- Macro per rc=-6 (SIGABRT) su macOS ----
|
||||
# Su macOS rc=-6 e' quasi sempre il sintomo di un crash di ffmpeg
|
||||
# dovuto al permesso Microfono mancante. La parola 'abort' non
|
||||
# sempre compare nello stderr di ffmpeg recenti.
|
||||
if rc == -6 and is_mac:
|
||||
return _permission_message()
|
||||
|
||||
# ---- Fallback: cerca le ultime righe significative ----
|
||||
for line in reversed(lines):
|
||||
l = line.strip()
|
||||
if not l:
|
||||
continue
|
||||
low = l.lower()
|
||||
# Riga AVFoundation/dshow specifica (es. "[avfoundation @ 0x...] Could not...")
|
||||
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
|
||||
return l
|
||||
if "error" in low or "fail" in low or "denied" in low or "cannot" in low:
|
||||
return l
|
||||
|
||||
# Ultima spiaggia: rc + ultime 3 righe di stderr per debug
|
||||
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
|
||||
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
|
||||
|
||||
|
||||
def _permission_message() -> str:
|
||||
"""Messaggio standardizzato per il problema permesso Microfono macOS."""
|
||||
return (
|
||||
"Permesso Microfono mancante. Su macOS:\n"
|
||||
" Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||
" Abilita 'Terminal' (se lanci l'app da terminale) o 'MusicTools'\n"
|
||||
" (se usi l'app installata). Poi riavvia l'app."
|
||||
)
|
||||
|
||||
|
||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||
"""Espone le ultime righe di stderr ai fini diagnostici.
|
||||
Chiamato dal bridge per la UI 'Mostra log tecnici'."""
|
||||
with _stderr_lock:
|
||||
return list(_stderr_buf[-max_lines:])
|
||||
|
||||
|
||||
def stop_recording() -> dict:
|
||||
"""Ferma la registrazione corrente (se attiva)."""
|
||||
_stop_event.set()
|
||||
return {"ok": True}
|
||||
@@ -175,3 +175,83 @@ def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]:
|
||||
else:
|
||||
name, tracks = get_playlist_info(token, item_id)
|
||||
return "Playlist", name, tracks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback senza credenziali (solo per singoli brani)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SpotifyAuthRequired(Exception):
|
||||
"""Sollevata quando un URL Spotify richiede le API keys (album/playlist)."""
|
||||
|
||||
|
||||
_OG_TITLE_RE = re.compile(
|
||||
r'<meta\s+property="og:title"\s+content="([^"]+)"', re.IGNORECASE,
|
||||
)
|
||||
_OG_DESC_RE = re.compile(
|
||||
r'<meta\s+property="og:description"\s+content="([^"]+)"', re.IGNORECASE,
|
||||
)
|
||||
# Tag HTML entities che ci interessano nelle og: (Spotify le scrive cosi').
|
||||
_HTML_ENTITIES = {
|
||||
"&": "&", "'": "'", "'": "'",
|
||||
""": '"', "<": "<", ">": ">",
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
@@ -9,7 +9,7 @@ import threading
|
||||
from pathlib import Path
|
||||
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"}
|
||||
|
||||
@@ -61,6 +61,7 @@ def get_bitrate(filepath: str | Path) -> int:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
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():
|
||||
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:
|
||||
raise ValueError("Ricerca fallita")
|
||||
|
||||
@@ -128,7 +129,7 @@ def update_cover_only(
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
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"))
|
||||
if not cover_files:
|
||||
@@ -152,6 +153,7 @@ def update_cover_only(
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except Exception:
|
||||
_cleanup_temp(temp_dir)
|
||||
@@ -284,6 +286,7 @@ def upgrade_folder(
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
@@ -0,0 +1,851 @@
|
||||
/* ============================================================
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
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; }
|
||||
@@ -0,0 +1,270 @@
|
||||
<!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-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>.zip</code> scaricato — viene estratto in <code>MusicTools.app</code></li>
|
||||
<li>Trascina <strong>MusicTools.app</strong> nella cartella <strong>Applicazioni</strong></li>
|
||||
<li>Vai in Applicazioni, <strong>tasto destro</strong> su MusicTools → <strong>Apri</strong> → <strong>Apri</strong> (solo la prima volta, e' la procedura standard per app non firmate)</li>
|
||||
<li>Inserisci email e chiave per attivare</li>
|
||||
</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>
|
||||
@@ -0,0 +1,426 @@
|
||||
<!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/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============== NAV ============== -->
|
||||
<header class="nav">
|
||||
<div class="container nav-row">
|
||||
<a href="#top" class="brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<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="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>Devo creare un account Spotify Premium o YouTube Premium?</summary>
|
||||
<p>No. Per Spotify ti basta un account gratuito (serve solo per ottenere le credenziali API dal loro dashboard, è gratis). YouTube non richiede account.</p>
|
||||
</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">
|
||||
<span class="brand-logo">♫</span>
|
||||
<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>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
<!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: 8 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>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, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
|
||||
<li>Sicurezza del software (verifica anti-abuso delle attivazioni) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||
</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.</p>
|
||||
|
||||
<h2>5. Terze parti</h2>
|
||||
<ul>
|
||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento.</li>
|
||||
<li><strong>Cloudflare Inc.</strong> — hosting infrastruttura backend (Workers, D1, R2, Pages).</li>
|
||||
<li><strong>Resend Inc.</strong> — invio email transazionali.</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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!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: 8 giugno 2026</p>
|
||||
|
||||
<h2>Garanzia 14 giorni</h2>
|
||||
<p>Hai 14 giorni di tempo dall'acquisto per chiedere il rimborso completo, senza fornire motivazioni. La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||
|
||||
<h2>Cosa succede dopo</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>Limitazioni</h2>
|
||||
<p>Trascorsi 14 giorni dall'acquisto non e' piu' possibile chiedere il rimborso, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,226 @@
|
||||
<!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: 8 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>
|
||||
|
||||
<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. Pagamenti 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>Prezzo pubblicato: <strong>39,90 € una tantum</strong> (IVA inclusa secondo paese di residenza dell'Utente).</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> dall'acquisto, senza fornire motivazioni.</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>
|
||||
|
||||
<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>
|
||||
@@ -1,4 +1,4 @@
|
||||
"""MusicDownload — Entry point (pywebview + WebKit/EdgeChromium)."""
|
||||
"""MusicTools — Entry point (pywebview + WebKit/EdgeChromium)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -23,7 +23,7 @@ def main():
|
||||
index_path = _resource_path("webui", "index.html")
|
||||
|
||||
window = webview.create_window(
|
||||
title="MusicDownload",
|
||||
title="MusicTools",
|
||||
url=index_path,
|
||||
js_api=api,
|
||||
width=1080,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pywebview>=5.0
|
||||
requests>=2.31.0
|
||||
yt-dlp>=2024.0.0
|
||||
mutagen>=1.47.0
|
||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
logs/
|
||||
dist/
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> — ${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 — 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 → Apri → 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Subscriptions: il record vero arriva con subscription_created.
|
||||
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione
|
||||
// non ancora generata), accettiamo l'evento ma non creiamo nulla.
|
||||
if (plan && plan.is_subscription) {
|
||||
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } };
|
||||
}
|
||||
|
||||
// Annual one-time (o piano non mappato -> trattalo come annual di default
|
||||
// cosi' non lasciamo l'utente senza licenza se ha pagato).
|
||||
const planForRecord = plan?.code || "annual";
|
||||
const limit = plan?.daily_limit ?? null;
|
||||
const expiresAt = 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 });
|
||||
} 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 (non ne creo un'altra).
|
||||
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 } };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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("-");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
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¤t=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);
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ===============================
|
||||
MusicDownload — Spotify-like UI
|
||||
MusicTools — Spotify-like UI
|
||||
=============================== */
|
||||
|
||||
:root {
|
||||
@@ -30,6 +30,18 @@
|
||||
--pink-hover: #F472B6;
|
||||
--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-hover: #FF4055;
|
||||
|
||||
@@ -46,6 +58,38 @@
|
||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Tema chiaro: override delle custom properties via data-theme="light"
|
||||
sull'elemento <html>. I colori brand (verde, blu, viola, ecc.) restano
|
||||
uguali per non perdere identita'. */
|
||||
html[data-theme="light"] {
|
||||
--bg: #FFFFFF;
|
||||
--bg-elev: #F7F7F8;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-card-2: #F2F3F5;
|
||||
--bg-input: #EDEEF1;
|
||||
--bg-input-hover: #E2E4E8;
|
||||
|
||||
--border: #E2E4E8;
|
||||
--divider: #ECEDF0;
|
||||
|
||||
--text: #111315;
|
||||
--text-2: #4A4D52;
|
||||
--text-3: #8A8E94;
|
||||
|
||||
--green-dim: #C8F0D5;
|
||||
--blue-dim: #D6E6FB;
|
||||
--purple-dim: #E9D8FD;
|
||||
--pink-dim: #FBCFE8;
|
||||
--amber-dim: #FDE68A;
|
||||
--rec-red-dim: #FECACA;
|
||||
--indigo-dim: #C7D2FE;
|
||||
|
||||
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -182,6 +226,67 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
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
|
||||
=============================== */
|
||||
@@ -233,6 +338,18 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
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-eyebrow {
|
||||
@@ -246,6 +363,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
.hero-eyebrow.blue { color: var(--blue); }
|
||||
.hero-eyebrow.purple { color: var(--purple); }
|
||||
.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 {
|
||||
font-size: 36px;
|
||||
@@ -463,6 +583,25 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
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 {
|
||||
background: #00CEC9;
|
||||
color: #000;
|
||||
@@ -578,30 +717,79 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.log .l-warn { color: #FDCB6E; }
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
padding: 10px 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(236, 72, 153, 0.08);
|
||||
border: 1px solid rgba(236, 72, 153, 0.2);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-2);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.hint-ico {
|
||||
color: var(--pink-hover);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.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
|
||||
=============================== */
|
||||
@@ -819,3 +1007,534 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
|
||||
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
|
||||
.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;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MusicDownload</title>
|
||||
<title>MusicTools</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,30 +15,55 @@
|
||||
<div class="brand-logo">
|
||||
<span>♫</span>
|
||||
</div>
|
||||
<div class="brand-name">MusicDownload</div>
|
||||
<div class="brand-name">MusicTools</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<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>Scarica</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="video">
|
||||
<button class="nav-item" data-view="video" data-feature="video">
|
||||
<span class="nav-icon">🎬</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade">
|
||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
</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">
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span>Impostazioni</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="guide">
|
||||
<span class="nav-icon">📖</span>
|
||||
<span>Guida</span>
|
||||
</button>
|
||||
</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-by">by LuZa</div>
|
||||
<div class="footer-version" id="footerVersion">v1.0</div>
|
||||
@@ -63,15 +88,22 @@
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">
|
||||
URL
|
||||
URL o titolo brano
|
||||
<span class="badge" id="urlListBadge" hidden></span>
|
||||
</label>
|
||||
<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">
|
||||
<span>📂</span> Carica lista
|
||||
</button>
|
||||
</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 class="field">
|
||||
@@ -136,6 +168,14 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<select id="videoQuality" class="input pill input-sm">
|
||||
<option value="best">Migliore disponibile</option>
|
||||
@@ -147,7 +187,10 @@
|
||||
|
||||
<div class="hint">
|
||||
<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>
|
||||
|
||||
@@ -237,6 +280,188 @@
|
||||
</div>
|
||||
</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 ===== -->
|
||||
<section class="view" id="view-settings">
|
||||
<header class="hero hero-purple">
|
||||
@@ -318,6 +543,22 @@
|
||||
</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>
|
||||
<div class="card">
|
||||
<div class="row spread">
|
||||
@@ -337,9 +578,318 @@
|
||||
</div>
|
||||
</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>
|
||||
</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 ============== -->
|
||||
<div class="modal-backdrop" id="guideModal" hidden>
|
||||
<div class="modal">
|
||||
@@ -355,6 +905,23 @@
|
||||
</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 ============== -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ======================================================
|
||||
MusicDownload — Frontend (vanilla JS)
|
||||
MusicTools — Frontend (vanilla JS)
|
||||
====================================================== */
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
@@ -7,6 +7,8 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
const state = {
|
||||
config: {},
|
||||
license: { licensed: false },
|
||||
purchaseUrl: "https://musictools.djluza.com",
|
||||
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
||||
dlOutputDir: "",
|
||||
upDir: "",
|
||||
@@ -15,6 +17,8 @@ const state = {
|
||||
downloading: false,
|
||||
upgrading: false,
|
||||
videoDownloading: false,
|
||||
meta: { path: "", newCoverPath: "", removeCover: false },
|
||||
rec: { devices: [], outputDir: "", recording: false, seconds: 0 },
|
||||
};
|
||||
|
||||
// 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
|
||||
// ============================================================
|
||||
@@ -37,6 +265,7 @@ window.bridge = {
|
||||
|
||||
const bridgeHandlers = {
|
||||
"log": ({ view, msg }) => appendLog(view, msg),
|
||||
"quota:update": (q) => renderQuotaBox(q),
|
||||
|
||||
"download:progress": (p) => {
|
||||
if (typeof p.overall === "number") {
|
||||
@@ -103,8 +332,64 @@ const bridgeHandlers = {
|
||||
$("#videoDownloadBtn").disabled = false;
|
||||
$("#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
|
||||
// ============================================================
|
||||
@@ -155,6 +440,11 @@ function showView(name) {
|
||||
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const t = theme === "light" ? "light" : "dark";
|
||||
document.documentElement.dataset.theme = t;
|
||||
}
|
||||
|
||||
$$(".nav-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => showView(btn.dataset.view));
|
||||
});
|
||||
@@ -166,10 +456,17 @@ async function init() {
|
||||
await waitApi();
|
||||
const data = await window.pywebview.api.get_init_data();
|
||||
state.config = data.config || {};
|
||||
state.license = data.license || { licensed: false };
|
||||
state.purchaseUrl = data.purchase_url || "https://musictools.djluza.com";
|
||||
$("#footerVersion").textContent = data.version;
|
||||
$("#currentVersionLabel").textContent = data.version;
|
||||
$("#guideBody").textContent = data.spotify_guide;
|
||||
|
||||
applyLicenseGate();
|
||||
renderLicenseStatus();
|
||||
applyPlanGate();
|
||||
refreshQuota();
|
||||
|
||||
// Populate Settings fields
|
||||
$("#clientIdInput").value = state.config.client_id || "";
|
||||
$("#clientSecretInput").value = state.config.client_secret || "";
|
||||
@@ -178,6 +475,7 @@ async function init() {
|
||||
$("#cookiesInput").value = state.config.cookies_path || "";
|
||||
$("#outputInput").value = state.config.output_dir || "";
|
||||
$("#themeSelect").value = state.config.theme || "dark";
|
||||
applyTheme(state.config.theme);
|
||||
|
||||
// Download tab — usa output_dir come default
|
||||
state.dlOutputDir = state.config.output_dir || "";
|
||||
@@ -195,6 +493,34 @@ async function init() {
|
||||
|
||||
// Upgrade tab — default threshold
|
||||
$("#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({
|
||||
tracks: state.loaded.tracks,
|
||||
output_dir: state.dlOutputDir,
|
||||
subfolder: state.loaded.name || "",
|
||||
});
|
||||
} else {
|
||||
let urls = state.loaded && state.loaded.kind === "urls" ? state.loaded.urls : [];
|
||||
if (!urls.length) {
|
||||
const single = $("#urlInput").value.trim();
|
||||
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"]();
|
||||
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({
|
||||
urls,
|
||||
@@ -274,7 +615,7 @@ $("#downloadBtn").addEventListener("click", async () => {
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||
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 () => {
|
||||
const url = $("#videoUrlInput").value.trim();
|
||||
if (!url) {
|
||||
@@ -317,9 +669,10 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
|
||||
urls: [url],
|
||||
output_dir: state.videoOutputDir,
|
||||
quality: $("#videoQuality").value,
|
||||
audio_only: videoMode === "audio",
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["video:done"]();
|
||||
}
|
||||
});
|
||||
@@ -380,7 +733,7 @@ $("#upgradeBtn").addEventListener("click", async () => {
|
||||
threshold: parseInt($("#upThreshold").value, 10) || 310,
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["upgrade:done"]();
|
||||
}
|
||||
});
|
||||
@@ -390,6 +743,274 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
|
||||
$("#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
|
||||
// ============================================================
|
||||
@@ -412,6 +1033,10 @@ $("#browseOutputBtn").addEventListener("click", async () => {
|
||||
if (path) $("#outputInput").value = path;
|
||||
});
|
||||
|
||||
$("#themeSelect").addEventListener("change", (e) => {
|
||||
applyTheme(e.target.value);
|
||||
});
|
||||
|
||||
$("#saveBtn").addEventListener("click", async () => {
|
||||
const payload = {
|
||||
client_id: $("#clientIdInput").value,
|
||||
@@ -422,6 +1047,7 @@ $("#saveBtn").addEventListener("click", async () => {
|
||||
output_dir: $("#outputInput").value,
|
||||
theme: $("#themeSelect").value,
|
||||
};
|
||||
applyTheme(payload.theme);
|
||||
const res = await window.pywebview.api.save_settings(payload);
|
||||
if (res.ok) {
|
||||
state.config = payload;
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||