Compare commits

..
11 Commits
Author SHA1 Message Date
luzadevandClaude Opus 4.7 05a2a58ace Tab Scarica: ricerca diretta per titolo o "Artista - Titolo"
L'input URL ora accetta anche query libere: se non sembra un link
(http/https/spotify:/dominio noto), il testo viene parsato come
{artist, name} e scaricato via download_playlist con una traccia.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:03:22 +02:00
23 changed files with 1198 additions and 258 deletions

No files matched your search

+14 -14
View File
@@ -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
+9
View File
@@ -34,3 +34,12 @@ 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/
-100
View File
@@ -1,100 +0,0 @@
1. Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)
2. Brunello, Vargas – Ghost Dance (Original Mix) (6:12)
3. Hardt Antoine – Raw (Original Mix) (5:25)
4. JUNO (DE) & Samm (BE) – Heart Spin (Original Mix) (5:56)
5. AIKON – To You (Original Mix) (6:12)
6. Adam Port, Keinemusik & SG Lewis – Be The One (Extended) (4:45)
7. HotLap – Recall (Extended Mix) (5:48)
8. JUNO (DE) & Samm (BE) – Muralli (Original Mix) (4:59)
9. Brunello – Science Fiction (Original Mix) (6:36)
10. Emanuel Satie, Tim Engelhardt, Maga, Sean Doron, Unfazed, Scenarios – A Gira (Extended Remix) (5:28)
11. DJ Koze – Spiralen (Original Mix) (7:17)
12. Joris Voorn, Taylr Renee & Roddy Lima – Machine (Original Mix) (5:02)
13. KREAM & Korolova – Annihilation (Extended Mix) (4:50)
14. Bedouin, Karl Williams, LUCH – The Only One (Original Mix) (6:49)
15. Massano & Silver Panda – Pa Ca (Original Mix) (4:47)
16. Moeaike, Arodes – Nobody Else (Extended Mix) (5:58)
17. RÜFÜS DU SOL – In the Moment (Adriatique Extended Remix) (5:12)
18. &ME, Black Coffee – The Rapture Pt.III (Original Mix) (8:02)
19. Liva K – Didn’t Miss You (Original Mix) (5:46)
20. Carlo Whale & TH;EN – Echoes of You (Original Mix) (5:22)
21. Magnus, JEWELS, YUMA., SOMMA, LE YORA – EVERYTHING IN ITS RIGHT PLACE (Original Mix) (7:14)
22. Andrea Oliva – Neon Hearts (Original Mix) (6:06)
23. Jackie Hollander – High On You (Extended Mix) (4:57)
24. KREAM, SCRIPT – Turn Up The Dose (Extended Mix) (4:20)
25. JOSEPH (CH) – B4U (Original Mix) (8:00)
26. Ajna (BE), Misha – Body Moving (Original Mix) (6:16)
27. Green Velvet & Harvard Bass – Lazer Beams (Adam Beyer & Massano Remix) (5:02)
28. Argy, Omiki & Lell Nahar – MANTRA (Extended Mix) (5:03)
29. Sasha & Franky Wah – Get On My Level (Original Mix) (8:00)
30. LUCH – UH HUH (Original Mix) (6:45)
31. Fahlberg – Feeling Good (Extended Mix) (6:49)
32. Ivory (IT) – Back Again (Original Mix) (5:42)
33. Massano, Y Do I – Lost My Mind (Original Mix) (5:23)
34. KOWSKI, Eric Reyes – Break Down! (Original Mix) (5:30)
35. Andrea Oliva – Spotlight (Original Mix) (6:21)
36. Tiga vs Meduza – You Gonna Want Me (I Know) (Extended Mix) (5:21)
37. Marsh – Beside (Extended Mix) (6:18)
38. Moderat, &ME, Rampa & Keinemusik – More Love (Rampa &ME Remix) (4:55)
39. Adriatique & Emmit Fenn – Closer (Extended Mix) (4:53)
40. Jimi Jules, Black Coffee – Trippy Yeah (Extended Mix) (6:40)
41. Liva K & OVEOUS – Blessings (Original Mix) (6:35)
42. Township Rebellion, Kasablanca – It’s Chemical (Original Mix) (6:28)
43. MAXI MERAKI, Sharing-DB.club – First Time (Original Mix) (5:19)
44. Pole Position – Touch Me (Hotlap Remix) (5:40)
45. Da Hool, Cassian & YOTTO – Love Parade (Extended Mix) (4:09)
46. Jody Wisternoff, Jamie McDermott – Citadel (Extended Mix) (5:17)
47. Super Flu, Dennis Louvra – Can’t Escape (Original Mix) (5:56)
48. Armin van Buuren, Argy, Marlo Rex – Like A Child (Extended Mix) (4:08)
49. Robert Babicz – The Wave (Original Mix) (5:08)
50. The Temper Trap – Sweet Disposition (John Summit & Silver Panda Extended Remix) (5:44)
51. Yotto, Tallac – Chemtrail Surfers (Extended Mix) (6:26)
52. HotLap – Waitin’ (Extended Mix) (6:35)
53. Miss Monique, Nicolas Taboada & FRANCO BA – Ghosts (Extended Mix) (5:28)
54. Picca & Mars & Moeaike – Move in Time (Original Mix) (5:39)
55. Hardt Antoine – Sing It Back (Extended Mix) (6:57)
56. Shakedown, Layton Giordani & Anyma (ofc) – At Night (Anyma x Layton Giordani Extended Remix) (4:35)
57. Acrobatik & whatlewisdoes – Lamazi (Extended Mix) (4:46)
58. Massano – Abraza (Original Mix) (5:13)
59. Alcatraz – Giv Me Luv (Jerome Isma-Ae Remix) (7:18)
60. Gespona – Komplex (Original Mix) (5:56)
61. Misha & Martim Rola – On Business (Extended Mix) (6:30)
62. Mind Against, Anakim & HUMAN404 – Late at Night (Extended Mix) (4:39)
63. Anyma (ofc), Argy & Son of Son – Voices In My Head (Extended Mix) (4:16)
64. Fred again.., Swedish House Mafia, Future – Turn On The Lights again.. (feat. Future) (Original Mix) (4:27)
65. Sezer Uysal & Axelax – Calling For More (Original Mix) (5:12)
66. LUCH, Marten Lou – Hideaway (Extended Version) (5:57)
67. Giuseppe Ottaviani, ARTBAT & Conor Ross – Break The Loop (Original Mix) (5:22)
68. Rhodes, CamelPhat, ARTBAT – For a Feeling (Adam Sellouk & Y do I Remix – Extended) (4:49)
69. Monolink, Artbat – Return to Oz (ARTBAT Remix) (8:00)
70. Lisa & Anyma (ofc) – Bad Angel (with Lisa) (Extended Mix) (4:08)
71. Florist (BE) – Unite (Original Mix) (6:25)
72. Moderat – Eating Hooks (Deep Dish Extended Remix) (7:13)
73. CamelPhat & Arodes – Cycles (Extended Mix) (6:52)
74. Interplanetary Criminal, Kettama – Yosemite (Extended) (6:02)
75. Leblanc (ofc) – Strings Anthem (Original Mix) (5:22)
76. Mind Against & Jos & Eli – Overture (Original Mix) (5:21)
77. Tripolism & Kingstarlight – Hurricane (Extended Mix) (4:49)
78. Luke Alexander – Last Night (feat. Y4MI) (Original Mix) (3:30)
79. Braxton – There’s Still Time (Extended Mix) (6:31)
80. Township Rebellion – Birds Fly First Class (Original Mix) (6:19)
81. Agents Of Time & Miss Monique – Rajada (Original Mix) (5:00)
82. Fancy Inc – Hypnotic (Extended Mix) (5:09)
83. Boys Noize, &ME, Rampa, Adam Port, Keinemusik, Vinson – Crazy For It (feat. Vinson) (Original Mix) (6:01)
84. Space Motion & Luca Belluco – Baila Conmigo (Original Mix) (4:38)
85. Tom Westy & Dansyn – Feel Your Soul (Extended) (4:33)
86. CamelPhat & Josh Gigante – The One (Extended Mix) (5:32)
87. Argy & Omnya – Aria (Extended Mix) (5:14)
88. Air & Tur, blou., Airsand, TuraniQa – Teach Me (Extended Mix) (4:35)
89. Dilby, Martin Ka – Moving Trough (Extened Mix) (6:16)
90. Pablo Say – Repique (Original Mix) (4:48)
91. Orsay & Kotiēr – 1886 (Original Mix) (5:46)
92. Adriatique & Elderbrook – Lost In The Woods (Extended Mix) (5:01)
93. Ajna (BE) & Sentin – Bringing The Noise (Original Mix) (5:45)
94. Eric Prydz – Pjanoo (Club Mix) (7:30)
95. Eelke Kleijn – Feel It, See It (Extended Mix) (6:07)
96. Aera – Litoral (Original Mix) (6:10)
97. Julian Koerndl – Horses (Original Mix) (6:09)
98. Supermode – Tell Me Why (MEDUZA Extended Remix) (8:28)
99. Laherte – Pump Up The Jam (Extended Mix) (3:58)
100. M.O.S. – Changes (Extended Mix) (6:43)
-7
View File
@@ -1,7 +0,0 @@
https://open.spotify.com/playlist/0Q4JvzeT0q3FLsj5fqffMV?si=d17c4002f6f349c4
https://open.spotify.com/playlist/4FHKFYziOZF75AkT3aR7QP?si=b013d1cc79ff47a3
https://open.spotify.com/playlist/0mpX2EakDLmFjifuh1fQKU?si=bab58f6a7e984560
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
https://open.spotify.com/playlist/4MmZgwwHgrSs2astI4NVxI?si=021eef1a758a42fc
https://open.spotify.com/playlist/4OAx11L58sozuyNVTBi6r4?si=27d4be41e53c4e8a
-18
View File
@@ -1,18 +0,0 @@
https://open.spotify.com/playlist/5HVNSaqs3HgzIsaCVeVGrI?si=dd86ade90d2347ae
https://open.spotify.com/playlist/7ptoSkENfnO1yEQYrly725?si=4d694c3390a54ce6
https://open.spotify.com/playlist/6VjXyFH9Z5HlGPAjRBKR32?si=e8fc98ee593e4e69
https://open.spotify.com/playlist/4rdb0iJ7hZhqgBFpXHGVU6?si=96a6838af2a3487f
https://open.spotify.com/playlist/51ireDdqSrt6juqBxkMsrm?si=6c06fc88e7354b50
https://open.spotify.com/playlist/50SNkybvZnubt5uK4F2kgk?si=b7a146e4d8a54d35
https://open.spotify.com/playlist/0erLbMzXD0U9xJUPlOeKrX?si=54c87940c7be4c65
https://open.spotify.com/playlist/3FGGBSXjLGU5kfPCxL9K6f?si=77e1eee04a0745aa
https://open.spotify.com/playlist/2OxJrcgA6AQGk1sSQ8ZZ2a?si=98ede53357124322
https://open.spotify.com/playlist/345eEKyGNyluygzMsS1n1H?si=9d4be71b28e94339
https://open.spotify.com/playlist/0Umelkk6D75eZHZuxkTtLF?si=7c2f1ff3d2354abb
https://open.spotify.com/playlist/2k9QmVpMHJfyjVVvJdnRLb?si=765f62a3caf843ef
https://open.spotify.com/playlist/7xVYvU65ezCBiOu49BSV4I?si=608741585f91483d
https://open.spotify.com/playlist/1AOMaSEZf0aChEOK6L5uI3?si=e44863bf2782419b
https://open.spotify.com/playlist/3Zn4AOuMAKFCJD4VLUD0uY?si=2179f9cb5e5b4092
https://open.spotify.com/playlist/1LA5WDfX5Z4I0fQSIx4L8N?si=9918a8eadb5744b9
https://open.spotify.com/playlist/0NHDUXVEsoFMwzkrYaRHG0?si=58e61fe6319e48cb
https://open.spotify.com/playlist/2Y6PQB7k73HCrBFwiBy2Jf?si=8da4990576cd444f
+50 -23
View File
@@ -1,4 +1,4 @@
# MusicDownload
# MusicTools
Applicazione desktop per **scaricare musica e video**, **modificare i metadati** dei file audio e **registrare l'audio** da qualsiasi ingresso del computer.
@@ -8,6 +8,8 @@ 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à
| Tab | Cosa fa |
@@ -75,34 +77,59 @@ Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTu
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.
### Installazione
### 1. Installazione
```bash
brew install blackhole-2ch
```
### Configurazione
> 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.
1. **Preferenze di Sistema** → **Audio** → **Uscita** → seleziona **BlackHole 2ch**
2. Apri la tab **● Registra** in MusicDownload e clicca **↻ Aggiorna**
3. Nel dropdown vedrai **🔄 BlackHole 2ch (loopback)** — selezionalo
4. Premi **● REC**, fai partire lo stream, premi **◼ Stop**
### 2. Permesso Microfono
### Vuoi sentire ancora l'audio mentre registri?
Anche se non è un microfono fisico, macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation.
Crea un **Multi-Output Device** in **Audio MIDI Setup**:
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.
1. Apri **Configurazione MIDI Audio** (`Utility → Audio MIDI Setup`)
2. `+` in basso a sinistra → **Crea dispositivo aggregato a uscita multipla**
3. Seleziona **BlackHole 2ch** + i tuoi altoparlanti/cuffie
4. Imposta questo Multi-Output come uscita di sistema
### 3. Setup A — minimo (registri ma non senti l'audio durante)
Così l'audio va contemporaneamente agli speaker (lo senti) e a BlackHole (viene registrato).
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)
@@ -144,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
@@ -157,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
@@ -166,14 +193,14 @@ 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:
@@ -186,10 +213,10 @@ 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>.
-5
View File
@@ -1,5 +0,0 @@
https://open.spotify.com/intl-it/album/3lgCqbQpmLscSRuWVMT5wS?si=UPiqK8smRgO_1JAAiZTrYg
https://open.spotify.com/intl-it/album/3pGb3m5KA1Y3gCVT6leCL3?si=Cj_OrTE5QAiEnwMD63EunA
https://open.spotify.com/intl-it/album/2ewhdpODXqoB0fXIKfq4u2?si=e2N7jWHiTWGiudHHMJNHHQ
https://open.spotify.com/intl-it/album/2xGfPfmx6faZUr9IaKEiSG?si=2Q-qMwWCTp-Yp7RIfhE2Ig
https://open.spotify.com/intl-it/album/43pKffJweYUVwEYcHPIfox?si=2E8IoUmOQp-cgsCujVBPMQ
+11 -6
View File
@@ -38,7 +38,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"
@@ -148,7 +148,7 @@ class Api:
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
try:
resp = requests.get(API_URL, headers={
"User-Agent": "MusicDownload",
"User-Agent": "MusicTools",
"Accept": "application/vnd.github+json",
}, timeout=10)
resp.raise_for_status()
@@ -640,6 +640,7 @@ 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"}
@@ -648,7 +649,7 @@ class Api:
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()
@@ -659,10 +660,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"
@@ -718,9 +721,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
+234
View File
@@ -0,0 +1,234 @@
"""Genera l'icona dell'app (PNG/ICNS/ICO) in modo riproducibile.
Stile: squircle (Apple-style rounded rect) con gradient verde Spotify
verso nero, nota musicale bianca al centro con leggero glow, highlight
sottile in alto.
Uso:
python3 assets/generate_icon.py
Produce:
assets/icon.png (1024x1024, master)
assets/icon.icns (macOS, generato via iconutil)
assets/icon.ico (Windows, multi-size)
assets/icon.iconset/... (intermedio macOS)
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Tuple
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
SIZE = 1024
RADIUS_RATIO = 0.225 # ~22% del lato, Apple iOS-like squircle
PADDING_RATIO = 0.085 # margine fra bordo immagine e squircle
# Palette (coerente col tema della UI)
GREEN_TOP = (30, 215, 96) # verde Spotify lucido
GREEN_BOTTOM = (15, 59, 34) # verde scuro profondo
WHITE = (255, 255, 255, 255)
HIGHLIGHT = (255, 255, 255, 38) # bianco semitrasparente per il riflesso
def vertical_gradient(size: int, top: Tuple[int, int, int],
bottom: Tuple[int, int, int]) -> Image.Image:
"""Crea un gradient verticale top -> bottom."""
img = Image.new("RGB", (size, size), top)
px = img.load()
for y in range(size):
t = y / (size - 1)
# ease (quad) per ammorbidire la transizione
t = t * t
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(size):
px[x, y] = (r, g, b)
return img
def squircle_mask(size: int, radius: int, padding: int) -> Image.Image:
"""Maschera alpha per un rounded rect centrato con padding."""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle(
(padding, padding, size - padding, size - padding),
radius=radius, fill=255,
)
return mask
def find_music_font() -> str:
"""Trova un font che contenga il glifo della nota musicale (♫)."""
candidates = [
"/System/Library/Fonts/Apple Symbols.ttf",
"/System/Library/Fonts/Symbol.ttf",
"/System/Library/Fonts/SFNS.ttf",
"/Library/Fonts/Arial Unicode.ttf",
]
for f in candidates:
if Path(f).exists():
return f
raise RuntimeError("Nessun font Unicode con simboli trovato")
def draw_note(img: Image.Image, glyph: str = "♫") -> None:
"""Stampa la nota musicale centrata, in bianco con leggero glow."""
font_path = find_music_font()
target_pt = int(SIZE * 0.62)
font = ImageFont.truetype(font_path, target_pt)
cx, cy = SIZE // 2, SIZE // 2 - int(SIZE * 0.01)
# Glow: stessa nota disegnata sfocata sotto in bianco semitrasparente
glow_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
glow_draw = ImageDraw.Draw(glow_layer)
glow_draw.text((cx, cy), glyph, font=font, fill=(255, 255, 255, 110),
anchor="mm")
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=24))
img.alpha_composite(glow_layer)
# Testo nitido sopra
draw = ImageDraw.Draw(img)
draw.text((cx, cy), glyph, font=font, fill=WHITE, anchor="mm")
def make_master() -> Image.Image:
"""Genera l'immagine master 1024x1024 RGBA con tutto."""
padding = int(SIZE * PADDING_RATIO)
radius = int(SIZE * RADIUS_RATIO)
# Background gradient verde
gradient = vertical_gradient(SIZE, GREEN_TOP, GREEN_BOTTOM)
# Squircle: prendiamo il gradient mascherato come forma
mask = squircle_mask(SIZE, radius, padding)
base = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
base.paste(gradient, (0, 0), mask)
# Highlight in alto: banda sottile (tipo "shine" iOS), molto trasparente
hl_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
hl_draw = ImageDraw.Draw(hl_layer)
hl_top = padding + int(SIZE * 0.04)
hl_bot = padding + int(SIZE * 0.16)
hl_left = padding + int(SIZE * 0.10)
hl_right = SIZE - padding - int(SIZE * 0.10)
hl_draw.ellipse(
(hl_left, hl_top, hl_right, hl_bot),
fill=(255, 255, 255, 30),
)
hl_layer = hl_layer.filter(ImageFilter.GaussianBlur(radius=18))
masked = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
masked.paste(hl_layer, (0, 0), mask)
base.alpha_composite(masked)
# Bordo interno sottile per dare definizione (stroke da 0.5%)
ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
ring_draw = ImageDraw.Draw(ring)
ring_draw.rounded_rectangle(
(padding, padding, SIZE - padding, SIZE - padding),
radius=radius,
outline=(255, 255, 255, 28),
width=max(2, int(SIZE * 0.004)),
)
base.alpha_composite(ring)
# Nota musicale al centro
draw_note(base)
# Lieve ombra interna in basso per profondita (vignette)
shade = Image.new("L", (SIZE, SIZE), 0)
sd = ImageDraw.Draw(shade)
sd.rounded_rectangle(
(padding, padding, SIZE - padding, SIZE - padding),
radius=radius, outline=255, width=int(SIZE * 0.025),
)
shade = shade.filter(ImageFilter.GaussianBlur(radius=int(SIZE * 0.025)))
overlay = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
overlay.paste((0, 0, 0, 60), mask=shade)
overlay = Image.new("RGBA", overlay.size, (0, 0, 0, 0)).convert("RGBA")
# Skip vignette complicata — base e' gia' coerente
return base
def export_iconset(master: Image.Image, outdir: Path) -> None:
"""Esporta le dimensioni standard macOS in outdir."""
outdir.mkdir(parents=True, exist_ok=True)
sizes = [
(16, "icon_16x16.png"),
(32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"),
(64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"),
(256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"),
(512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"),
(1024, "icon_512x512@2x.png"),
]
for px, name in sizes:
img = master.resize((px, px), Image.LANCZOS)
img.save(outdir / name, "PNG")
def build_icns(iconset_dir: Path, output_icns: Path) -> bool:
"""Usa iconutil di macOS per generare il .icns. Ritorna True se ok."""
if not shutil.which("iconutil"):
return False
try:
subprocess.run(
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(output_icns)],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def build_ico(master: Image.Image, output_ico: Path) -> None:
"""Genera il .ico multi-size per Windows."""
sizes = [(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)]
master.save(output_ico, format="ICO",
sizes=sizes, append_images=[])
def main():
print(f">>> Genero icona master {SIZE}x{SIZE}...")
master = make_master()
master_path = HERE / "icon.png"
master.save(master_path, "PNG")
print(f" {master_path}")
iconset_dir = HERE / "icon.iconset"
if iconset_dir.exists():
shutil.rmtree(iconset_dir)
print(">>> Esporto iconset macOS...")
export_iconset(master, iconset_dir)
icns_path = HERE / "icon.icns"
if build_icns(iconset_dir, icns_path):
print(f" {icns_path}")
else:
print(" SKIP icns (iconutil non disponibile)")
print(">>> Genero icon.ico per Windows...")
ico_path = HERE / "icon.ico"
build_ico(master, ico_path)
print(f" {ico_path}")
print("\nDone.")
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+15 -10
View File
@@ -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,12 +208,17 @@ 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)
@@ -225,12 +230,12 @@ def run_pyinstaller():
*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
@@ -238,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}")
@@ -249,7 +254,7 @@ def run_pyinstaller():
# =========================================================================
def main():
print("=" * 50)
print(" MusicDownload — Build macOS")
print(" MusicTools — Build macOS")
print("=" * 50)
# Verifica pyinstaller
@@ -272,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()
+15 -12
View File
@@ -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)
@@ -121,16 +124,16 @@ def run_pyinstaller():
*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")
# =========================================================================
@@ -138,7 +141,7 @@ def run_pyinstaller():
# =========================================================================
def main():
print("=" * 50)
print(" MusicDownload - Build Windows")
print(" MusicTools - Build Windows")
print("=" * 50)
if sys.platform != "win32":
@@ -167,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()
+22 -4
View File
@@ -5,24 +5,42 @@ import os
import sys
from pathlib import Path
VERSION = "v1.2.2"
VERSION = "v1.5.2"
APP_NAME = "MusicTools"
_LEGACY_NAME = "MusicDownload"
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
+43 -19
View File
@@ -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:
+13
View File
@@ -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.
+90 -22
View File
@@ -15,7 +15,7 @@ import time
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ffmpeg
from core.paths import find_ffmpeg, subprocess_flags
_IS_MAC = sys.platform == "darwin"
@@ -29,6 +29,8 @@ _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:
@@ -77,6 +79,7 @@ def _list_macos(ffmpeg: str) -> list[dict]:
[ffmpeg, "-hide_banner", "-f", "avfoundation",
"-list_devices", "true", "-i", ""],
capture_output=True, text=True, timeout=10,
**subprocess_flags(),
)
except Exception:
return []
@@ -114,6 +117,7 @@ def _list_windows(ffmpeg: str) -> list[dict]:
[ffmpeg, "-hide_banner", "-f", "dshow",
"-list_devices", "true", "-i", "dummy"],
capture_output=True, text=True, timeout=10,
**subprocess_flags(),
)
except Exception:
return []
@@ -216,6 +220,24 @@ def start_recording(
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
@@ -224,16 +246,42 @@ def start_recording(
_current_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=False, # bytes su stdin per "q"
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)})
start = time.monotonic()
# Pubblica un tick al secondo
while True:
if _stop_event.is_set():
break
@@ -271,12 +319,7 @@ def start_recording(
if rc == 0 or out.exists():
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
else:
err = ""
try:
err = (_get_last_stderr() or "").strip().splitlines()[-1]
except Exception:
pass
progress_callback("error", {"error": err or f"ffmpeg exit {rc}"})
progress_callback("error", {"error": _extract_friendly_error(rc)})
except Exception as e:
with _proc_lock:
_current_proc = None
@@ -289,17 +332,42 @@ def start_recording(
return {"ok": True, "output_path": str(out)}
def _get_last_stderr() -> str:
"""Tenta di leggere lo stderr residuo del processo corrente."""
with _proc_lock:
proc = _current_proc
if proc is None or proc.stderr is None:
return ""
try:
data = proc.stderr.read() or b""
return data.decode("utf-8", errors="ignore")
except Exception:
return ""
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, ..."""
with _stderr_lock:
lines = list(_stderr_buf)
text = "\n".join(lines).lower()
if "abort" in text and rc == -6 and sys.platform == "darwin":
return (
"ffmpeg abortito (errore -6): probabilmente l'app non ha il "
"permesso 'Microfono' di macOS. Vai su Preferenze di Sistema "
"→ Privacy e sicurezza → Microfono e abilita Terminal/Python. "
"Poi riavvia l'app."
)
if any(s in text for s in ("input/output error", "errno 22", "device not configured")):
return "Dispositivo non disponibile (riavvia il servizio audio o ricontrolla la selezione)."
if "permission" in text or "not authorized" in text or "not permitted" in text:
return "Permesso microfono negato da macOS. Abilita Python/MusicTools in Privacy → Microfono."
if "no such device" in text or "no such audio device" in text:
return "Dispositivo non trovato. Premi '↻ Aggiorna' e riseleziona."
# Restituisci l'ultima riga utile dello stderr (esclude le righe di banner/progress)
for line in reversed(lines):
l = line.strip()
if not l:
continue
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
return l
if "error" in l.lower() or "fail" in l.lower() or "denied" in l.lower():
return l
return f"ffmpeg exit {rc}"
def stop_recording() -> dict:
+6 -3
View File
@@ -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:
+2 -2
View File
@@ -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,
+271 -4
View File
@@ -1,5 +1,5 @@
/* ===============================
MusicDownload — Spotify-like UI
MusicTools — Spotify-like UI
=============================== */
:root {
@@ -38,6 +38,10 @@
--rec-red-hover: #F87171;
--rec-red-dim: #7F1D1D;
--indigo: #6366F1;
--indigo-hover: #818CF8;
--indigo-dim: #312E81;
--red: #E22134;
--red-hover: #FF4055;
@@ -249,6 +253,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
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 {
@@ -264,6 +272,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.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;
@@ -615,30 +624,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
=============================== */
@@ -989,3 +1047,212 @@ input[type="number"]::-webkit-inner-spin-button {
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;
}
}
+305 -6
View File
@@ -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,7 +15,7 @@
<div class="brand-logo">
<span>♫</span>
</div>
<div class="brand-name">MusicDownload</div>
<div class="brand-name">MusicTools</div>
</div>
<div class="divider"></div>
@@ -45,6 +45,10 @@
<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="footer">
@@ -71,15 +75,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">
@@ -144,6 +155,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>
@@ -155,7 +174,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>
@@ -270,7 +292,13 @@
<div class="hint" id="recHint">
<span class="hint-ico">ⓘ</span>
Per registrare l'audio di sistema (Spotify/YouTube/ecc.) installa <strong>BlackHole</strong> (gratuito, brew install blackhole-2ch) e poi imposta l'output di sistema su BlackHole.
<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>
@@ -511,6 +539,277 @@
</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>
+98 -3
View File
@@ -1,5 +1,5 @@
/* ======================================================
MusicDownload — Frontend (vanilla JS)
MusicTools — Frontend (vanilla JS)
====================================================== */
const $ = (sel) => document.querySelector(sel);
@@ -27,6 +27,24 @@ 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 };
}
// ============================================================
// Bridge — eventi spinti dal backend
// ============================================================
@@ -340,11 +358,25 @@ $("#downloadBtn").addEventListener("click", async () => {
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) {
toast(res.error || "Errore", "error");
bridgeHandlers["download:done"]();
}
return;
}
}
res = await window.pywebview.api.start_download({
urls,
@@ -375,6 +407,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) {
@@ -396,6 +439,7 @@ $("#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");
@@ -474,6 +518,57 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
// ============================================================
$("#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) {