Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05a2a58ace | ||
|
|
8610f4d95c | ||
|
|
b47fdd1ab3 | ||
|
|
e56a975b77 | ||
|
|
52002f5184 | ||
|
|
878e024c13 | ||
|
|
07794052c8 | ||
|
|
850d3f4b6c | ||
|
|
6f5c32165a | ||
|
|
39c92f44f4 | ||
|
|
9f37b7eb94 |
No files matched your search
+14
-14
@@ -1,4 +1,4 @@
|
|||||||
name: Build MusicDownload
|
name: Build MusicTools
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -36,14 +36,14 @@ jobs:
|
|||||||
- name: Package .app -> zip
|
- name: Package .app -> zip
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
cd dist
|
||||||
ditto -c -k --sequesterRsrc --keepParent MusicDownload.app ../MusicDownload-macOS.zip
|
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip
|
||||||
ls -lh ../MusicDownload-macOS.zip
|
ls -lh ../MusicTools-macOS.zip
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicDownload-macOS
|
name: MusicTools-macOS
|
||||||
path: MusicDownload-macOS.zip
|
path: MusicTools-macOS.zip
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -69,14 +69,14 @@ jobs:
|
|||||||
- name: Package -> zip
|
- name: Package -> zip
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
Compress-Archive -Path dist/MusicDownload/* -DestinationPath MusicDownload-Windows.zip -CompressionLevel Optimal
|
Compress-Archive -Path dist/MusicTools/* -DestinationPath MusicTools-Windows.zip -CompressionLevel Optimal
|
||||||
Get-Item MusicDownload-Windows.zip
|
Get-Item MusicTools-Windows.zip
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicDownload-Windows
|
name: MusicTools-Windows
|
||||||
path: MusicDownload-Windows.zip
|
path: MusicTools-Windows.zip
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -90,22 +90,22 @@ jobs:
|
|||||||
- name: Download macOS artifact
|
- name: Download macOS artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicDownload-macOS
|
name: MusicTools-macOS
|
||||||
path: artifacts/macos
|
path: artifacts/macos
|
||||||
|
|
||||||
- name: Download Windows artifact
|
- name: Download Windows artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicDownload-Windows
|
name: MusicTools-Windows
|
||||||
path: artifacts/windows
|
path: artifacts/windows
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
name: MusicDownload ${{ github.ref_name }}
|
name: MusicTools ${{ github.ref_name }}
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
files: |
|
files: |
|
||||||
artifacts/macos/MusicDownload-macOS.zip
|
artifacts/macos/MusicTools-macOS.zip
|
||||||
artifacts/windows/MusicDownload-Windows.zip
|
artifacts/windows/MusicTools-Windows.zip
|
||||||
@@ -34,3 +34,12 @@ MUSICA/
|
|||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
Apri con claude-social.command
|
Apri con claude-social.command
|
||||||
|
|
||||||
|
# Playlist personali (non vanno nel repo)
|
||||||
|
Vibes_List.txt
|
||||||
|
Beatport Top 100*.txt
|
||||||
|
PlayList_2026.txt
|
||||||
|
PlayList_da_scaricare.txt
|
||||||
|
|
||||||
|
# Asset intermedi rigenerabili (vedi assets/generate_icon.py)
|
||||||
|
assets/icon.iconset/
|
||||||
@@ -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,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.
|
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>
|
Repo: <https://github.com/luzadev/musicdownload>
|
||||||
|
|
||||||
|
> ℹ️ Il repo Git conserva il nome storico **`musicdownload`** (l'app prima si chiamava così); URL stabili per release già pubblicate e installazioni esistenti.
|
||||||
|
|
||||||
## Funzionalità
|
## Funzionalità
|
||||||
|
|
||||||
| Tab | Cosa fa |
|
| 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.
|
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
|
```bash
|
||||||
brew install blackhole-2ch
|
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. Permesso Microfono
|
||||||
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**
|
|
||||||
|
|
||||||
### 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`)
|
### 3. Setup A — minimo (registri ma non senti l'audio durante)
|
||||||
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
|
|
||||||
|
|
||||||
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
|
## Struttura progetto
|
||||||
|
|
||||||
```
|
```
|
||||||
MusicDownload/
|
MusicTools/
|
||||||
├── main.py # Entry point — bootstrap pywebview
|
├── main.py # Entry point — bootstrap pywebview
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── build_macos.py # Script build macOS (.app)
|
├── build_macos.py # Script build macOS (.app)
|
||||||
@@ -144,8 +171,8 @@ Il file `config.json` viene creato automaticamente al primo avvio:
|
|||||||
|
|
||||||
Posizione:
|
Posizione:
|
||||||
- **Sviluppo**: directory del progetto
|
- **Sviluppo**: directory del progetto
|
||||||
- **macOS bundle**: `~/Library/Application Support/MusicDownload/`
|
- **macOS bundle**: `~/Library/Application Support/MusicTools/`
|
||||||
- **Windows bundle**: `%APPDATA%/MusicDownload/`
|
- **Windows bundle**: `%APPDATA%/MusicTools/`
|
||||||
|
|
||||||
## Build locale
|
## Build locale
|
||||||
|
|
||||||
@@ -157,7 +184,7 @@ pip install pyinstaller
|
|||||||
python3 build_macos.py
|
python3 build_macos.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
|
Produce `dist/MusicTools.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
@@ -166,14 +193,14 @@ pip install pyinstaller
|
|||||||
python build_windows.py
|
python build_windows.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Produce `dist/MusicDownload/MusicDownload.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build.
|
Produce `dist/MusicTools/MusicTools.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build.
|
||||||
|
|
||||||
## Build & Release automatiche (GitHub Actions)
|
## Build & Release automatiche (GitHub Actions)
|
||||||
|
|
||||||
Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`:
|
Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`:
|
||||||
|
|
||||||
1. **`build-macos`** (runner `macos-latest`) → `MusicDownload-macOS.zip` (la `.app` zippata con `ditto`)
|
1. **`build-macos`** (runner `macos-latest`) → `MusicTools-macOS.zip` (la `.app` zippata con `ditto`)
|
||||||
2. **`build-windows`** (runner `windows-latest`) → `MusicDownload-Windows.zip`
|
2. **`build-windows`** (runner `windows-latest`) → `MusicTools-Windows.zip`
|
||||||
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
|
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
|
||||||
|
|
||||||
Per pubblicare una nuova versione:
|
Per pubblicare una nuova versione:
|
||||||
@@ -186,10 +213,10 @@ git push origin v1.3.0
|
|||||||
Per fare solo una build di test (senza release):
|
Per fare solo una build di test (senza release):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh workflow run "Build MusicDownload" --ref main
|
gh workflow run "Build MusicTools" --ref main
|
||||||
```
|
```
|
||||||
|
|
||||||
oppure dalla UI: **Actions → Build MusicDownload → Run workflow**.
|
oppure dalla UI: **Actions → Build MusicTools → Run workflow**.
|
||||||
|
|
||||||
Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>.
|
Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -38,7 +38,7 @@ SPOTIFY_GUIDE_TEXT = """\
|
|||||||
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
|
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
|
||||||
2) Clicca "Create app".
|
2) Clicca "Create app".
|
||||||
3) Compila:
|
3) Compila:
|
||||||
- App name: MusicDownload (o quello che vuoi)
|
- App name: MusicTools (o quello che vuoi)
|
||||||
- Description: a piacere
|
- Description: a piacere
|
||||||
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
|
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
|
||||||
- Seleziona "Web API"
|
- Seleziona "Web API"
|
||||||
@@ -148,7 +148,7 @@ class Api:
|
|||||||
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
|
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
|
||||||
try:
|
try:
|
||||||
resp = requests.get(API_URL, headers={
|
resp = requests.get(API_URL, headers={
|
||||||
"User-Agent": "MusicDownload",
|
"User-Agent": "MusicTools",
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
}, timeout=10)
|
}, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
@@ -640,6 +640,7 @@ class Api:
|
|||||||
urls = payload.get("urls") or []
|
urls = payload.get("urls") or []
|
||||||
output_dir = (payload.get("output_dir") or "").strip()
|
output_dir = (payload.get("output_dir") or "").strip()
|
||||||
quality = payload.get("quality") or "1080p"
|
quality = payload.get("quality") or "1080p"
|
||||||
|
audio_only = bool(payload.get("audio_only", False))
|
||||||
|
|
||||||
if not urls:
|
if not urls:
|
||||||
return {"ok": False, "error": "Nessun URL fornito"}
|
return {"ok": False, "error": "Nessun URL fornito"}
|
||||||
@@ -648,7 +649,7 @@ class Api:
|
|||||||
|
|
||||||
self._video_thread = threading.Thread(
|
self._video_thread = threading.Thread(
|
||||||
target=self._video_worker,
|
target=self._video_worker,
|
||||||
args=(list(urls), output_dir, quality),
|
args=(list(urls), output_dir, quality, audio_only),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._video_thread.start()
|
self._video_thread.start()
|
||||||
@@ -659,10 +660,12 @@ class Api:
|
|||||||
self._log("video", "[INFO] Interruzione richiesta...")
|
self._log("video", "[INFO] Interruzione richiesta...")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
def _video_worker(self, urls: list, output_dir: str, quality: str) -> None:
|
def _video_worker(self, urls: list, output_dir: str, quality: str,
|
||||||
|
audio_only: bool = False) -> None:
|
||||||
reset_download_stop()
|
reset_download_stop()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
cookies_path = cfg.get("cookies_path", "")
|
cookies_path = cfg.get("cookies_path", "")
|
||||||
|
bitrate = cfg.get("bitrate", "320K")
|
||||||
total_urls = len(urls)
|
total_urls = len(urls)
|
||||||
multi = total_urls > 1
|
multi = total_urls > 1
|
||||||
view = "video"
|
view = "video"
|
||||||
@@ -718,9 +721,11 @@ class Api:
|
|||||||
|
|
||||||
self._emit("video:progress", payload_evt)
|
self._emit("video:progress", payload_evt)
|
||||||
|
|
||||||
self._log(view, f"[INFO] Video: {url}")
|
mode_lbl = f"Solo audio MP3 {bitrate}" if audio_only else f"Video {quality}"
|
||||||
|
self._log(view, f"[INFO] {mode_lbl}: {url}")
|
||||||
try:
|
try:
|
||||||
download_video(url, output_dir, quality, cookies_path, progress_cb)
|
download_video(url, output_dir, quality, cookies_path, progress_cb,
|
||||||
|
audio_only=audio_only, audio_bitrate=bitrate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(view, f"[ERRORE] {e}")
|
self._log(view, f"[ERRORE] {e}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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()
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+15
-10
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Build script per creare MusicDownload.app su macOS.
|
Build script per creare MusicTools.app su macOS.
|
||||||
|
|
||||||
Uso:
|
Uso:
|
||||||
python3 build_macos.py
|
python3 build_macos.py
|
||||||
@@ -8,7 +8,7 @@ Uso:
|
|||||||
Cosa fa:
|
Cosa fa:
|
||||||
1. Scarica il binario standalone di yt-dlp per macOS
|
1. Scarica il binario standalone di yt-dlp per macOS
|
||||||
2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew
|
2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew
|
||||||
3. Esegue PyInstaller per creare MusicDownload.app
|
3. Esegue PyInstaller per creare MusicTools.app
|
||||||
|
|
||||||
Requisiti:
|
Requisiti:
|
||||||
pip3 install pyinstaller
|
pip3 install pyinstaller
|
||||||
@@ -208,12 +208,17 @@ def run_pyinstaller():
|
|||||||
if f.is_file():
|
if f.is_file():
|
||||||
add_binaries.extend(["--add-binary", f"{f}:lib"])
|
add_binaries.extend(["--add-binary", f"{f}:lib"])
|
||||||
|
|
||||||
|
icon_path = ROOT / "assets" / "icon.icns"
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, "-m", "PyInstaller",
|
sys.executable, "-m", "PyInstaller",
|
||||||
"--name", "MusicDownload",
|
"--name", "MusicTools",
|
||||||
"--windowed",
|
"--windowed",
|
||||||
"--onedir",
|
"--onedir",
|
||||||
"--noconfirm",
|
"--noconfirm",
|
||||||
|
]
|
||||||
|
if icon_path.exists():
|
||||||
|
cmd.extend(["--icon", str(icon_path)])
|
||||||
|
cmd.extend([
|
||||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||||
"--add-data", f"{ROOT / 'webui'}:webui",
|
"--add-data", f"{ROOT / 'webui'}:webui",
|
||||||
# Hidden imports per pywebview (WebKit su macOS)
|
# Hidden imports per pywebview (WebKit su macOS)
|
||||||
@@ -225,12 +230,12 @@ def run_pyinstaller():
|
|||||||
*add_binaries,
|
*add_binaries,
|
||||||
# Entry point
|
# Entry point
|
||||||
str(ROOT / "main.py"),
|
str(ROOT / "main.py"),
|
||||||
]
|
])
|
||||||
|
|
||||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||||
|
|
||||||
app_path = DIST_DIR / "MusicDownload.app"
|
app_path = DIST_DIR / "MusicTools.app"
|
||||||
if app_path.exists():
|
if app_path.exists():
|
||||||
log(f"Build completata: {app_path}")
|
log(f"Build completata: {app_path}")
|
||||||
# Mostra dimensione
|
# Mostra dimensione
|
||||||
@@ -238,8 +243,8 @@ def run_pyinstaller():
|
|||||||
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
||||||
else:
|
else:
|
||||||
log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller")
|
log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller")
|
||||||
# Potrebbe essere in dist/MusicDownload/ (non .app)
|
# Potrebbe essere in dist/MusicTools/ (non .app)
|
||||||
alt = DIST_DIR / "MusicDownload"
|
alt = DIST_DIR / "MusicTools"
|
||||||
if alt.exists():
|
if alt.exists():
|
||||||
log(f"Trovata directory: {alt}")
|
log(f"Trovata directory: {alt}")
|
||||||
|
|
||||||
@@ -249,7 +254,7 @@ def run_pyinstaller():
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
def main():
|
def main():
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(" MusicDownload — Build macOS")
|
print(" MusicTools — Build macOS")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
# Verifica pyinstaller
|
# Verifica pyinstaller
|
||||||
@@ -272,12 +277,12 @@ def main():
|
|||||||
print("\n" + "=" * 50)
|
print("\n" + "=" * 50)
|
||||||
print(" DONE!")
|
print(" DONE!")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
app = DIST_DIR / "MusicDownload.app"
|
app = DIST_DIR / "MusicTools.app"
|
||||||
if app.exists():
|
if app.exists():
|
||||||
print(f"\n L'app e in: {app}")
|
print(f"\n L'app e in: {app}")
|
||||||
print(f" Per testarla: open '{app}'")
|
print(f" Per testarla: open '{app}'")
|
||||||
print(f"\n Per distribuirla, comprimi la cartella:")
|
print(f"\n Per distribuirla, comprimi la cartella:")
|
||||||
print(f" zip -r MusicDownload.zip dist/MusicDownload.app")
|
print(f" zip -r MusicTools.zip dist/MusicTools.app")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-12
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Build script per creare MusicDownload.exe su Windows.
|
Build script per creare MusicTools.exe su Windows.
|
||||||
|
|
||||||
Uso:
|
Uso:
|
||||||
python build_windows.py
|
python build_windows.py
|
||||||
@@ -7,7 +7,7 @@ Uso:
|
|||||||
Cosa fa:
|
Cosa fa:
|
||||||
1. Scarica il binario standalone di yt-dlp per Windows
|
1. Scarica il binario standalone di yt-dlp per Windows
|
||||||
2. Scarica ffmpeg static build per Windows
|
2. Scarica ffmpeg static build per Windows
|
||||||
3. Esegue PyInstaller per creare MusicDownload.exe
|
3. Esegue PyInstaller per creare MusicTools.exe
|
||||||
|
|
||||||
Requisiti:
|
Requisiti:
|
||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
@@ -99,14 +99,17 @@ def run_pyinstaller():
|
|||||||
if f.is_file() and f.suffix in (".exe", ""):
|
if f.is_file() and f.suffix in (".exe", ""):
|
||||||
add_binaries.extend(["--add-binary", f"{f};."])
|
add_binaries.extend(["--add-binary", f"{f};."])
|
||||||
|
|
||||||
|
icon_path = ROOT / "assets" / "icon.ico"
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, "-m", "PyInstaller",
|
sys.executable, "-m", "PyInstaller",
|
||||||
"--name", "MusicDownload",
|
"--name", "MusicTools",
|
||||||
"--windowed",
|
"--windowed",
|
||||||
"--onedir",
|
"--onedir",
|
||||||
"--noconfirm",
|
"--noconfirm",
|
||||||
# Icona (se presente)
|
]
|
||||||
# "--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
|
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||||
"--add-data", f"{ROOT / 'webui'};webui",
|
"--add-data", f"{ROOT / 'webui'};webui",
|
||||||
# Hidden imports per pywebview (EdgeChromium su Windows)
|
# Hidden imports per pywebview (EdgeChromium su Windows)
|
||||||
@@ -121,16 +124,16 @@ def run_pyinstaller():
|
|||||||
*add_binaries,
|
*add_binaries,
|
||||||
# Entry point
|
# Entry point
|
||||||
str(ROOT / "main.py"),
|
str(ROOT / "main.py"),
|
||||||
]
|
])
|
||||||
|
|
||||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||||
|
|
||||||
exe_path = DIST_DIR / "MusicDownload" / "MusicDownload.exe"
|
exe_path = DIST_DIR / "MusicTools" / "MusicTools.exe"
|
||||||
if exe_path.exists():
|
if exe_path.exists():
|
||||||
log(f"Build completata: {exe_path}")
|
log(f"Build completata: {exe_path}")
|
||||||
else:
|
else:
|
||||||
log("ATTENZIONE: MusicDownload.exe non trovato, controlla l'output")
|
log("ATTENZIONE: MusicTools.exe non trovato, controlla l'output")
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -138,7 +141,7 @@ def run_pyinstaller():
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
def main():
|
def main():
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(" MusicDownload - Build Windows")
|
print(" MusicTools - Build Windows")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
@@ -167,12 +170,12 @@ def main():
|
|||||||
print("\n" + "=" * 50)
|
print("\n" + "=" * 50)
|
||||||
print(" DONE!")
|
print(" DONE!")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
dist = DIST_DIR / "MusicDownload"
|
dist = DIST_DIR / "MusicTools"
|
||||||
if dist.exists():
|
if dist.exists():
|
||||||
print(f"\n L'app e in: {dist}")
|
print(f"\n L'app e in: {dist}")
|
||||||
print(f" Eseguibile: {dist / 'MusicDownload.exe'}")
|
print(f" Eseguibile: {dist / 'MusicTools.exe'}")
|
||||||
print(f"\n Per distribuirla, comprimi la cartella:")
|
print(f"\n Per distribuirla, comprimi la cartella:")
|
||||||
print(f" Comprimi {dist} -> MusicDownload_Windows.zip")
|
print(f" Comprimi {dist} -> MusicTools_Windows.zip")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-4
@@ -5,24 +5,42 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.2.2"
|
VERSION = "v1.5.2"
|
||||||
|
|
||||||
|
|
||||||
|
APP_NAME = "MusicTools"
|
||||||
|
_LEGACY_NAME = "MusicDownload"
|
||||||
|
|
||||||
|
|
||||||
def _get_config_dir() -> Path:
|
def _get_config_dir() -> Path:
|
||||||
"""Ritorna la directory per config.json.
|
"""Ritorna la directory per config.json.
|
||||||
|
|
||||||
Frozen (bundle PyInstaller):
|
Frozen (bundle PyInstaller):
|
||||||
- macOS: ~/Library/Application Support/MusicDownload/
|
- macOS: ~/Library/Application Support/MusicTools/
|
||||||
- Windows: %APPDATA%/MusicDownload/
|
- Windows: %APPDATA%/MusicTools/
|
||||||
Dev (non frozen):
|
Dev (non frozen):
|
||||||
- directory del progetto
|
- directory del progetto
|
||||||
|
|
||||||
|
Se la cartella nuova non esiste ma quella legacy 'MusicDownload' si',
|
||||||
|
viene migrata (copia di config.json) cosi' l'utente non perde le
|
||||||
|
impostazioni dopo il rename.
|
||||||
"""
|
"""
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
base = Path(os.environ.get("APPDATA", Path.home()))
|
base = Path(os.environ.get("APPDATA", Path.home()))
|
||||||
else:
|
else:
|
||||||
base = Path.home() / "Library" / "Application Support"
|
base = Path.home() / "Library" / "Application Support"
|
||||||
config_dir = base / "MusicDownload"
|
config_dir = base / APP_NAME
|
||||||
|
legacy_dir = base / _LEGACY_NAME
|
||||||
|
# Migrazione one-shot: se non esiste la nuova ma esiste la vecchia
|
||||||
|
if not config_dir.exists() and legacy_dir.exists():
|
||||||
|
try:
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
legacy_cfg = legacy_dir / "config.json"
|
||||||
|
if legacy_cfg.exists():
|
||||||
|
(config_dir / "config.json").write_bytes(legacy_cfg.read_bytes())
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return config_dir
|
return config_dir
|
||||||
return Path(__file__).resolve().parent.parent
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|||||||
+33
-9
@@ -9,7 +9,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from core.paths import find_ytdlp, find_ffmpeg_dir
|
from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
|
||||||
|
|
||||||
|
|
||||||
# Flag globale per interruzione
|
# Flag globale per interruzione
|
||||||
@@ -102,7 +102,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
|
|||||||
if cookies_path and Path(cookies_path).exists():
|
if cookies_path and Path(cookies_path).exists():
|
||||||
cmd.extend(["--cookies", cookies_path])
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
|
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
|
||||||
|
|
||||||
@@ -146,8 +146,10 @@ def download_playlist(
|
|||||||
progress_callback(i, total, "", "stopped", 0)
|
progress_callback(i, total, "", "stopped", 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
query = f"{track['name']} - {track['artist']}"
|
artist = (track.get('artist') or "").strip()
|
||||||
track_only = track['name']
|
name = (track.get('name') or "").strip()
|
||||||
|
query = f"{name} - {artist}" if artist else name
|
||||||
|
track_only = name
|
||||||
|
|
||||||
# Salta brani gia scaricati in precedenza (file di tracking)
|
# Salta brani gia scaricati in precedenza (file di tracking)
|
||||||
if query in done_set:
|
if query in done_set:
|
||||||
@@ -204,6 +206,7 @@ def download_playlist(
|
|||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = subprocess.Popen(
|
_current_process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
@@ -278,7 +281,7 @@ def download_direct_url(
|
|||||||
probe_cmd.extend(["--cookies", cookies_path])
|
probe_cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError(result.stderr.strip())
|
raise ValueError(result.stderr.strip())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -356,6 +359,7 @@ def download_direct_url(
|
|||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = subprocess.Popen(
|
_current_process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
@@ -418,10 +422,14 @@ def download_video(
|
|||||||
quality: str = "1080p",
|
quality: str = "1080p",
|
||||||
cookies_path: Optional[str] = None,
|
cookies_path: Optional[str] = None,
|
||||||
progress_callback: Optional[Callable] = None,
|
progress_callback: Optional[Callable] = None,
|
||||||
|
audio_only: bool = False,
|
||||||
|
audio_bitrate: str = "320K",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Scarica video (con audio) da YouTube/TikTok/Instagram/Facebook/etc.
|
"""Scarica video (con audio) o solo audio da YouTube/TikTok/Instagram/Facebook/etc.
|
||||||
|
|
||||||
quality: "best" | "1080p" | "720p" | "480p"
|
quality: "best" | "1080p" | "720p" | "480p" (ignorato se audio_only=True)
|
||||||
|
audio_only: True -> estrae solo audio in MP3
|
||||||
|
audio_bitrate: bitrate MP3 quando audio_only=True
|
||||||
Per Instagram/Facebook privati servono cookies validi.
|
Per Instagram/Facebook privati servono cookies validi.
|
||||||
"""
|
"""
|
||||||
global _current_process
|
global _current_process
|
||||||
@@ -439,7 +447,7 @@ def download_video(
|
|||||||
probe_cmd.extend(["--cookies", cookies_path])
|
probe_cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError(result.stderr.strip())
|
raise ValueError(result.stderr.strip())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -461,7 +469,7 @@ def download_video(
|
|||||||
return
|
return
|
||||||
|
|
||||||
total = len(entries)
|
total = len(entries)
|
||||||
fmt = _video_format(quality)
|
fmt = _video_format(quality) if not audio_only else "bestaudio/best"
|
||||||
|
|
||||||
for i, entry in enumerate(entries):
|
for i, entry in enumerate(entries):
|
||||||
if is_stopped():
|
if is_stopped():
|
||||||
@@ -483,6 +491,21 @@ def download_video(
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(i, total, title, "downloading", 0)
|
progress_callback(i, total, title, "downloading", 0)
|
||||||
|
|
||||||
|
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 = [
|
cmd = [
|
||||||
ytdlp,
|
ytdlp,
|
||||||
"-f", fmt,
|
"-f", fmt,
|
||||||
@@ -503,6 +526,7 @@ def download_video(
|
|||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = subprocess.Popen(
|
_current_process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ def _exe(name: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def subprocess_flags() -> dict:
|
||||||
|
"""Kwargs per subprocess.run/Popen che nascondono la console su Windows.
|
||||||
|
|
||||||
|
Senza CREATE_NO_WINDOW, ogni subprocess (yt-dlp, ffmpeg, ffprobe) lanciato
|
||||||
|
da un'app GUI PyInstaller apre una finestra cmd nera per la durata del
|
||||||
|
processo. Su macOS/Linux il flag non esiste, quindi ritorniamo dict vuoto.
|
||||||
|
"""
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
# 0x08000000 == CREATE_NO_WINDOW
|
||||||
|
return {"creationflags": 0x08000000}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def find_ytdlp() -> str:
|
def find_ytdlp() -> str:
|
||||||
"""Trova il path di yt-dlp.
|
"""Trova il path di yt-dlp.
|
||||||
|
|
||||||
|
|||||||
+90
-22
@@ -15,7 +15,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from core.paths import find_ffmpeg
|
from core.paths import find_ffmpeg, subprocess_flags
|
||||||
|
|
||||||
|
|
||||||
_IS_MAC = sys.platform == "darwin"
|
_IS_MAC = sys.platform == "darwin"
|
||||||
@@ -29,6 +29,8 @@ _current_proc: Optional[subprocess.Popen] = None
|
|||||||
_proc_lock = threading.Lock()
|
_proc_lock = threading.Lock()
|
||||||
_stop_event = threading.Event()
|
_stop_event = threading.Event()
|
||||||
_recording_thread: Optional[threading.Thread] = None
|
_recording_thread: Optional[threading.Thread] = None
|
||||||
|
_stderr_buf: list = []
|
||||||
|
_stderr_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def is_recording() -> bool:
|
def is_recording() -> bool:
|
||||||
@@ -77,6 +79,7 @@ def _list_macos(ffmpeg: str) -> list[dict]:
|
|||||||
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
||||||
"-list_devices", "true", "-i", ""],
|
"-list_devices", "true", "-i", ""],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
@@ -114,6 +117,7 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
|||||||
[ffmpeg, "-hide_banner", "-f", "dshow",
|
[ffmpeg, "-hide_banner", "-f", "dshow",
|
||||||
"-list_devices", "true", "-i", "dummy"],
|
"-list_devices", "true", "-i", "dummy"],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
@@ -216,6 +220,24 @@ def start_recording(
|
|||||||
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
|
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
|
||||||
|
|
||||||
_stop_event.clear()
|
_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():
|
def _worker():
|
||||||
global _current_proc
|
global _current_proc
|
||||||
@@ -224,16 +246,42 @@ def start_recording(
|
|||||||
_current_proc = subprocess.Popen(
|
_current_proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE,
|
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:
|
if progress_callback:
|
||||||
progress_callback("started", {"output_path": str(out)})
|
progress_callback("started", {"output_path": str(out)})
|
||||||
|
|
||||||
start = time.monotonic()
|
|
||||||
# Pubblica un tick al secondo
|
|
||||||
while True:
|
while True:
|
||||||
if _stop_event.is_set():
|
if _stop_event.is_set():
|
||||||
break
|
break
|
||||||
@@ -271,12 +319,7 @@ def start_recording(
|
|||||||
if rc == 0 or out.exists():
|
if rc == 0 or out.exists():
|
||||||
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
||||||
else:
|
else:
|
||||||
err = ""
|
progress_callback("error", {"error": _extract_friendly_error(rc)})
|
||||||
try:
|
|
||||||
err = (_get_last_stderr() or "").strip().splitlines()[-1]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
progress_callback("error", {"error": err or f"ffmpeg exit {rc}"})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with _proc_lock:
|
with _proc_lock:
|
||||||
_current_proc = None
|
_current_proc = None
|
||||||
@@ -289,17 +332,42 @@ def start_recording(
|
|||||||
return {"ok": True, "output_path": str(out)}
|
return {"ok": True, "output_path": str(out)}
|
||||||
|
|
||||||
|
|
||||||
def _get_last_stderr() -> str:
|
def _extract_friendly_error(rc: int) -> str:
|
||||||
"""Tenta di leggere lo stderr residuo del processo corrente."""
|
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
|
||||||
with _proc_lock:
|
Riconosce i casi tipici: permessi microfono, device occupato,
|
||||||
proc = _current_proc
|
device non disponibile, ..."""
|
||||||
if proc is None or proc.stderr is None:
|
with _stderr_lock:
|
||||||
return ""
|
lines = list(_stderr_buf)
|
||||||
try:
|
|
||||||
data = proc.stderr.read() or b""
|
text = "\n".join(lines).lower()
|
||||||
return data.decode("utf-8", errors="ignore")
|
|
||||||
except Exception:
|
if "abort" in text and rc == -6 and sys.platform == "darwin":
|
||||||
return ""
|
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:
|
def stop_recording() -> dict:
|
||||||
|
|||||||
+6
-3
@@ -9,7 +9,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe
|
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_flags
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ def get_bitrate(filepath: str | Path) -> int:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
bit_rate = int(data.get("format", {}).get("bit_rate", 0))
|
bit_rate = int(data.get("format", {}).get("bit_rate", 0))
|
||||||
@@ -94,7 +95,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> str:
|
|||||||
if cookies_path and Path(cookies_path).exists():
|
if cookies_path and Path(cookies_path).exists():
|
||||||
cmd.extend(["--cookies", cookies_path])
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError("Ricerca fallita")
|
raise ValueError("Ricerca fallita")
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ def update_cover_only(
|
|||||||
if cookies_path and Path(cookies_path).exists():
|
if cookies_path and Path(cookies_path).exists():
|
||||||
cmd.extend(["--cookies", cookies_path])
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
subprocess.run(cmd, capture_output=True, timeout=60)
|
subprocess.run(cmd, capture_output=True, timeout=60, **subprocess_flags())
|
||||||
|
|
||||||
cover_files = list(temp_dir.glob("cover*.jpg"))
|
cover_files = list(temp_dir.glob("cover*.jpg"))
|
||||||
if not cover_files:
|
if not cover_files:
|
||||||
@@ -152,6 +153,7 @@ def update_cover_only(
|
|||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
_cleanup_temp(temp_dir)
|
_cleanup_temp(temp_dir)
|
||||||
@@ -284,6 +286,7 @@ def upgrade_folder(
|
|||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = subprocess.Popen(
|
_current_process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""MusicDownload — Entry point (pywebview + WebKit/EdgeChromium)."""
|
"""MusicTools — Entry point (pywebview + WebKit/EdgeChromium)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -23,7 +23,7 @@ def main():
|
|||||||
index_path = _resource_path("webui", "index.html")
|
index_path = _resource_path("webui", "index.html")
|
||||||
|
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
title="MusicDownload",
|
title="MusicTools",
|
||||||
url=index_path,
|
url=index_path,
|
||||||
js_api=api,
|
js_api=api,
|
||||||
width=1080,
|
width=1080,
|
||||||
|
|||||||
+271
-4
@@ -1,5 +1,5 @@
|
|||||||
/* ===============================
|
/* ===============================
|
||||||
MusicDownload — Spotify-like UI
|
MusicTools — Spotify-like UI
|
||||||
=============================== */
|
=============================== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -38,6 +38,10 @@
|
|||||||
--rec-red-hover: #F87171;
|
--rec-red-hover: #F87171;
|
||||||
--rec-red-dim: #7F1D1D;
|
--rec-red-dim: #7F1D1D;
|
||||||
|
|
||||||
|
--indigo: #6366F1;
|
||||||
|
--indigo-hover: #818CF8;
|
||||||
|
--indigo-dim: #312E81;
|
||||||
|
|
||||||
--red: #E22134;
|
--red: #E22134;
|
||||||
--red-hover: #FF4055;
|
--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%);
|
background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-indigo {
|
||||||
|
background: linear-gradient(135deg, #4F46E5 0%, #312E81 60%, #1E1B4B 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
||||||
|
|
||||||
.hero-eyebrow {
|
.hero-eyebrow {
|
||||||
@@ -264,6 +272,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
.hero-eyebrow.pink { color: var(--pink-hover); }
|
.hero-eyebrow.pink { color: var(--pink-hover); }
|
||||||
.hero-eyebrow.amber { color: var(--amber-hover); }
|
.hero-eyebrow.amber { color: var(--amber-hover); }
|
||||||
.hero-eyebrow.red { color: var(--rec-red-hover); }
|
.hero-eyebrow.red { color: var(--rec-red-hover); }
|
||||||
|
.hero-eyebrow.indigo { color: var(--indigo-hover); }
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
@@ -615,30 +624,79 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
.log .l-warn { color: #FDCB6E; }
|
.log .l-warn { color: #FDCB6E; }
|
||||||
.log .l-muted { color: var(--text-3); }
|
.log .l-muted { color: var(--text-3); }
|
||||||
|
|
||||||
|
/* ===============================
|
||||||
|
SEGMENTED CONTROL
|
||||||
|
=============================== */
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-input);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-btn:hover:not(.active) { color: var(--text); }
|
||||||
|
|
||||||
|
.seg-btn.active {
|
||||||
|
background: var(--pink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===============================
|
/* ===============================
|
||||||
HINT BOX
|
HINT BOX
|
||||||
=============================== */
|
=============================== */
|
||||||
.hint {
|
.hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding: 10px 14px;
|
padding: 12px 14px;
|
||||||
background: rgba(236, 72, 153, 0.08);
|
background: rgba(236, 72, 153, 0.08);
|
||||||
border: 1px solid rgba(236, 72, 153, 0.2);
|
border: 1px solid rgba(236, 72, 153, 0.2);
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint-ico {
|
.hint-ico {
|
||||||
color: var(--pink-hover);
|
color: var(--pink-hover);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint strong { color: var(--text); font-weight: 700; }
|
.hint strong { color: var(--text); font-weight: 700; }
|
||||||
|
|
||||||
|
.hint code {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint a {
|
||||||
|
color: var(--green-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ===============================
|
/* ===============================
|
||||||
CHECKBOX
|
CHECKBOX
|
||||||
=============================== */
|
=============================== */
|
||||||
@@ -989,3 +1047,212 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-right: 10px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+304
-5
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>MusicDownload</title>
|
<title>MusicTools</title>
|
||||||
<link rel="stylesheet" href="css/style.css" />
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<span>♫</span>
|
<span>♫</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-name">MusicDownload</div>
|
<div class="brand-name">MusicTools</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@@ -45,6 +45,10 @@
|
|||||||
<span class="nav-icon">⚙</span>
|
<span class="nav-icon">⚙</span>
|
||||||
<span>Impostazioni</span>
|
<span>Impostazioni</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-view="guide">
|
||||||
|
<span class="nav-icon">📖</span>
|
||||||
|
<span>Guida</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
@@ -71,15 +75,22 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">
|
<label class="field-label">
|
||||||
URL
|
URL o titolo brano
|
||||||
<span class="badge" id="urlListBadge" hidden></span>
|
<span class="badge" id="urlListBadge" hidden></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="text" id="urlInput" class="input pill" placeholder="Incolla qui un link Spotify, YouTube o SoundCloud…" />
|
<input type="text" id="urlInput" class="input pill" placeholder="Link Spotify/YouTube… oppure Artista - Titolo · Titolo brano" />
|
||||||
<button class="btn btn-ghost pill" id="loadListBtn">
|
<button class="btn btn-ghost pill" id="loadListBtn">
|
||||||
<span>📂</span> Carica lista
|
<span>📂</span> Carica lista
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
<span class="hint-ico">ⓘ</span>
|
||||||
|
<div>
|
||||||
|
Puoi incollare un <strong>link</strong> (Spotify, YouTube, SoundCloud…) oppure scrivere direttamente
|
||||||
|
<strong>Artista - Titolo</strong> o solo il <strong>titolo del brano</strong>: l'app cerca il match migliore su YouTube e scarica in MP3.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -144,6 +155,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field inline">
|
<div class="field inline">
|
||||||
|
<label class="field-label">Modalità</label>
|
||||||
|
<div class="segmented" id="videoMode">
|
||||||
|
<button type="button" class="seg-btn active" data-mode="video">🎬 Video (MP4)</button>
|
||||||
|
<button type="button" class="seg-btn" data-mode="audio">🎵 Solo audio (MP3)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field inline" id="videoQualityField">
|
||||||
<label class="field-label">Qualità</label>
|
<label class="field-label">Qualità</label>
|
||||||
<select id="videoQuality" class="input pill input-sm">
|
<select id="videoQuality" class="input pill input-sm">
|
||||||
<option value="best">Migliore disponibile</option>
|
<option value="best">Migliore disponibile</option>
|
||||||
@@ -155,9 +174,12 @@
|
|||||||
|
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
<span class="hint-ico">ⓘ</span>
|
<span class="hint-ico">ⓘ</span>
|
||||||
|
<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).
|
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card actions">
|
<div class="card actions">
|
||||||
<button class="btn btn-pink pill btn-lg" id="videoDownloadBtn">
|
<button class="btn btn-pink pill btn-lg" id="videoDownloadBtn">
|
||||||
@@ -270,7 +292,13 @@
|
|||||||
|
|
||||||
<div class="hint" id="recHint">
|
<div class="hint" id="recHint">
|
||||||
<span class="hint-ico">ⓘ</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -511,6 +539,277 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== VIEW: GUIDA ===== -->
|
||||||
|
<section class="view" id="view-guide">
|
||||||
|
<header class="hero hero-indigo">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-eyebrow indigo">GUIDA</div>
|
||||||
|
<h1 class="hero-title">Manuale d'uso</h1>
|
||||||
|
<p class="hero-subtitle">Come usare MusicTools al meglio · setup · troubleshooting</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-deco">📖</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="guide-layout">
|
||||||
|
<!-- TOC sticky -->
|
||||||
|
<nav class="guide-toc">
|
||||||
|
<div class="micro-label" style="margin-bottom: 8px;">Sommario</div>
|
||||||
|
<a href="#g-intro" class="toc-link">Introduzione</a>
|
||||||
|
<a href="#g-setup" class="toc-link">Setup iniziale</a>
|
||||||
|
<a href="#g-download" class="toc-link">Tab Scarica</a>
|
||||||
|
<a href="#g-video" class="toc-link">Tab Video</a>
|
||||||
|
<a href="#g-upgrade" class="toc-link">Tab Upgrade</a>
|
||||||
|
<a href="#g-meta" class="toc-link">Tab Metadati</a>
|
||||||
|
<a href="#g-record" class="toc-link">Tab Registra</a>
|
||||||
|
<a href="#g-settings" class="toc-link">Impostazioni</a>
|
||||||
|
<a href="#g-update" class="toc-link">Aggiornamenti</a>
|
||||||
|
<a href="#g-troubleshoot" class="toc-link">Troubleshooting</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<article class="guide-content">
|
||||||
|
|
||||||
|
<section id="g-intro" class="g-section">
|
||||||
|
<h2>Introduzione</h2>
|
||||||
|
<p><strong>MusicTools</strong> è una app desktop tutto-in-uno per chi lavora con la musica: scarica brani e video da decine di piattaforme, migliora la qualità dei file esistenti, modifica i tag/metadati e registra l'audio da qualsiasi ingresso del computer.</p>
|
||||||
|
<p>L'interfaccia è organizzata in 6 sezioni accessibili dalla barra laterale: <span class="kbd">⬇ Scarica</span>, <span class="kbd">🎬 Video</span>, <span class="kbd">⚡ Upgrade</span>, <span class="kbd">🏷 Metadati</span>, <span class="kbd">● Registra</span>, <span class="kbd">⚙ Impostazioni</span>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-setup" class="g-section">
|
||||||
|
<h2>Setup iniziale</h2>
|
||||||
|
<p>Prima di usare l'app, conviene fare 3 cose nelle <strong>Impostazioni</strong>:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Cartella output predefinita</strong> — dove vengono salvati tutti i download.</li>
|
||||||
|
<li><strong>Credenziali Spotify</strong> (solo se vuoi scaricare da link Spotify) — vedi il bottone "Come ottenere le credenziali?" nella tab Impostazioni.</li>
|
||||||
|
<li><strong>cookies.txt</strong> (opzionale) — necessario solo per scaricare contenuti privati Instagram/Facebook o aggirare rate-limit YouTube. Esportalo dal browser con l'estensione "Get cookies.txt LOCALLY".</li>
|
||||||
|
</ol>
|
||||||
|
<p>Tutti i campi vengono salvati automaticamente quando premi <strong>Salva Impostazioni</strong>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-download" class="g-section">
|
||||||
|
<h2>Tab ⬇ Scarica</h2>
|
||||||
|
<p>Scarica musica in MP3 al bitrate impostato in Impostazioni (default 320 kbps).</p>
|
||||||
|
|
||||||
|
<h3>1 · Da Spotify</h3>
|
||||||
|
<p>Incolla un link a una <strong>playlist, album o brano</strong> (es. <code>open.spotify.com/playlist/...</code>). L'app risolve i metadati via API Spotify e scarica i brani da YouTube. <strong>Richiede</strong> le credenziali API impostate.</p>
|
||||||
|
|
||||||
|
<h3>2 · Da YouTube, SoundCloud e altri</h3>
|
||||||
|
<p>Incolla qualsiasi URL supportato da yt-dlp (singolo video o playlist). Funziona anche con Bandcamp, Mixcloud, ecc.</p>
|
||||||
|
|
||||||
|
<h3>3 · Lista da file .txt</h3>
|
||||||
|
<p>Clicca <strong>📂 Carica lista</strong>. Il file può contenere:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Una URL per riga</strong> — l'app le scarica una dopo l'altra.</li>
|
||||||
|
<li><strong>Tracklist con titoli</strong> (es. Beatport Top 100): formato <code>1. Artista – Titolo (variant) (durata)</code>. L'app riconosce automaticamente il formato e cerca ogni brano su YouTube.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Nel caso tracklist, viene creata automaticamente una <strong>sottocartella</strong> col nome del file (es. <code>Beatport Top 100.txt</code> → <code>Beatport Top 100/</code>).</p>
|
||||||
|
|
||||||
|
<h3>4 · Skip duplicati</h3>
|
||||||
|
<p>Se riesegui la stessa playlist, i brani già scaricati vengono saltati in automatico. Il tracking funziona in due modi:</p>
|
||||||
|
<ul>
|
||||||
|
<li>File <code>.downloaded_tracks</code> dentro la cartella output.</li>
|
||||||
|
<li>Scan dei file presenti, con match dei nomi token-per-token (ignora parole come "Official Video", "Lyrics", "HD"...).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-video" class="g-section">
|
||||||
|
<h2>Tab 🎬 Video</h2>
|
||||||
|
<p>Scarica video da <strong>YouTube, TikTok, Instagram, Facebook</strong> e oltre 1000 altri siti supportati da yt-dlp.</p>
|
||||||
|
|
||||||
|
<h3>Modalità</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>🎬 Video (MP4)</strong> — output video+audio in MP4, qualità a scelta (best / 1080p / 720p / 480p).</li>
|
||||||
|
<li><strong>🎵 Solo audio (MP3)</strong> — estrae solo l'audio in MP3 al bitrate impostato in Impostazioni, con thumbnail embedded come copertina.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Contenuti privati</h3>
|
||||||
|
<p>Per scaricare reel Instagram privati, post Facebook privati o aggirare rate-limit YouTube serve un file <strong><code>cookies.txt</code></strong> valido (formato Netscape). Esportalo dal browser e impostalo in <strong>Impostazioni → Percorsi</strong>.</p>
|
||||||
|
|
||||||
|
<div class="g-box info">
|
||||||
|
<strong>Esempio:</strong> per scaricare la <em>Storia</em> di un utente Instagram seguito, fai login al browser, esporta i cookies, riavvia l'app.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-upgrade" class="g-section">
|
||||||
|
<h2>Tab ⚡ Upgrade qualità</h2>
|
||||||
|
<p>Scansiona una cartella e <strong>riscarica i file sotto la soglia kbps</strong> impostata, sostituendoli con la versione a qualità maggiore.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Seleziona la cartella con i file audio.</li>
|
||||||
|
<li>Imposta la <strong>Soglia HQ (kbps)</strong> — i file con bitrate inferiore verranno riprocessati. Default 310 kbps.</li>
|
||||||
|
<li>(Opzionale) Spunta <strong>Includi sottocartelle</strong> per la scansione ricorsiva.</li>
|
||||||
|
<li>Premi <strong>Avvia Upgrade</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
<p>Per ogni file l'app cerca la versione migliore su YouTube e la sostituisce mantenendo nome originale. I file già processati vengono saltati grazie al tracking <code>.upgraded_tracks</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-meta" class="g-section">
|
||||||
|
<h2>Tab 🏷 Metadati</h2>
|
||||||
|
<p>Editor completo dei tag audio. Apri un file con <strong>📂 Apri file audio</strong>.</p>
|
||||||
|
|
||||||
|
<h3>Formati supportati</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>MP3</strong> — ID3v2.3</li>
|
||||||
|
<li><strong>M4A / AAC / MP4</strong> — Apple atoms</li>
|
||||||
|
<li><strong>FLAC</strong> — Vorbis comments</li>
|
||||||
|
<li><strong>WAV</strong> — ID3 embedded + fallback al chunk RIFF LIST/INFO (per file taggati da ffmpeg/Audacity)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Campi editabili</h3>
|
||||||
|
<p>Titolo, artista, album, album artist, anno, traccia (formato <code>1/12</code>), genere, BPM, key musicale, commento, copertina (JPG/PNG), e su macOS anche <strong>kMDItemWhereFroms</strong> (l'extended attribute che traccia l'origine del file scaricato).</p>
|
||||||
|
|
||||||
|
<h3>Copertina</h3>
|
||||||
|
<p>Anteprima 180×180 della copertina corrente. <strong>Cambia copertina</strong> ne carica una nuova; <strong>Rimuovi</strong> la marca per la rimozione al prossimo salvataggio.</p>
|
||||||
|
|
||||||
|
<h3>Annulla modifiche</h3>
|
||||||
|
<p>Premi <strong>↺ Ricarica</strong> per rileggere i tag dal file e scartare le modifiche non salvate.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-record" class="g-section">
|
||||||
|
<h2>Tab ● Registra</h2>
|
||||||
|
<p>Cattura audio in MP3 da qualsiasi ingresso del computer: <strong>mixer, scheda audio esterna, microfono, driver loopback</strong> (BlackHole/Soundflower/VB-Cable). Bitrate selezionabile 128–320 kbps.</p>
|
||||||
|
|
||||||
|
<h3>Setup standard (mixer/microfono)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Dropdown <strong>Dispositivo</strong>: seleziona la sorgente.</li>
|
||||||
|
<li>Imposta una cartella output e (opzionale) un nome file.</li>
|
||||||
|
<li>Premi <strong>● REC</strong> → registrazione, timer in tempo reale.</li>
|
||||||
|
<li><strong>◼ Stop</strong> → file MP3 finalizzato e linkato sotto.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Registrare l'audio di sistema su macOS (Spotify, YouTube...)</h3>
|
||||||
|
<p>macOS non espone nativamente l'uscita audio. Serve un driver loopback gratuito, il più diffuso è <strong>BlackHole</strong>.</p>
|
||||||
|
|
||||||
|
<h4>Installazione</h4>
|
||||||
|
<p>Apri Terminale e lancia:</p>
|
||||||
|
<pre><code>brew install blackhole-2ch</code></pre>
|
||||||
|
<p>Se subito dopo l'install BlackHole non compare nel dropdown, riavvia il servizio audio:</p>
|
||||||
|
<pre><code>sudo killall coreaudiod</code></pre>
|
||||||
|
<p>(rilancia da solo in pochi secondi, nessun rischio)</p>
|
||||||
|
|
||||||
|
<h4>Permesso microfono</h4>
|
||||||
|
<p>macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation. Vai su <strong>Preferenze di Sistema → Privacy e sicurezza → Microfono</strong> e abilita Terminal/MusicTools. Se la voce non c'è, comparirà un popup la prima volta che premi REC.</p>
|
||||||
|
|
||||||
|
<h4>Setup A — minimo (non senti l'audio durante)</h4>
|
||||||
|
<ol>
|
||||||
|
<li>Click icona altoparlante nella barra menu → <strong>Output → BlackHole 2ch</strong></li>
|
||||||
|
<li>Fai partire Spotify (non sentirai nulla — l'audio va a BlackHole)</li>
|
||||||
|
<li>Tab Registra → dispositivo <strong>🔄 BlackHole 2ch (loopback)</strong> → REC</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h4>Setup B — completo (registri E senti l'audio)</h4>
|
||||||
|
<ol>
|
||||||
|
<li>Apri <strong>Configurazione MIDI Audio</strong> (<code>Applicazioni → Utility</code>)</li>
|
||||||
|
<li>In basso a sinistra: <strong>+ → Crea dispositivo aggregato a uscita multipla</strong></li>
|
||||||
|
<li>Spunta sia <strong>BlackHole 2ch</strong> sia i tuoi <strong>Altoparlanti integrati</strong></li>
|
||||||
|
<li>Imposta <strong>BlackHole 2ch come Master Device</strong> (menu in alto)</li>
|
||||||
|
<li>Abilita <strong>Drift Correction</strong> sulla riga degli altoparlanti</li>
|
||||||
|
<li>(Opzionale) Rinomina in <code>Speakers + BlackHole</code></li>
|
||||||
|
<li>Preferenze di Sistema → Audio → Uscita → seleziona <code>Speakers + BlackHole</code></li>
|
||||||
|
<li>Nella tab Registra: scegli <strong>🔄 BlackHole 2ch (loopback)</strong>, non il dispositivo aggregato</li>
|
||||||
|
</ol>
|
||||||
|
<div class="g-box success">
|
||||||
|
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-settings" class="g-section">
|
||||||
|
<h2>Impostazioni</h2>
|
||||||
|
|
||||||
|
<h3>Spotify API</h3>
|
||||||
|
<p>Le credenziali API Spotify sono <strong>gratuite</strong> e non richiedono Premium. Vai su <a href="#" data-ext="https://developer.spotify.com/dashboard">Spotify Developer Dashboard</a>, "Create app", compila i campi (Redirect URI <code>http://localhost:8888/callback</code>, seleziona "Web API"), e copia Client ID + Client Secret nei campi corrispondenti.</p>
|
||||||
|
|
||||||
|
<h3>Download</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Bitrate</strong> — qualità target per gli MP3 (128/192/256/320 kbps).</li>
|
||||||
|
<li><strong>Soglia HQ</strong> — soglia kbps per la tab Upgrade. I file sotto questa soglia vengono riscaricati.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Percorsi</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>cookies.txt</strong> — per contenuti privati/protetti.</li>
|
||||||
|
<li><strong>Cartella output predefinita</strong> — viene proposta in tutte le tab.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-update" class="g-section">
|
||||||
|
<h2>Aggiornamenti</h2>
|
||||||
|
<p>In <strong>Impostazioni → Aggiornamenti</strong> trovi il bottone <strong>Controlla aggiornamenti</strong>. L'app interroga la pagina release di GitHub e ti mostra se c'è una versione nuova.</p>
|
||||||
|
<p>Se è disponibile un aggiornamento, comparirà un secondo bottone <strong>Scarica aggiornamento</strong> che apre direttamente il browser sull'asset corretto (macOS o Windows) della release più recente.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-troubleshoot" class="g-section">
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
|
||||||
|
<table class="g-table">
|
||||||
|
<thead><tr><th>Sintomo</th><th>Causa probabile</th><th>Fix</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>BlackHole non compare nel dropdown della tab Registra</td>
|
||||||
|
<td>CoreAudio non l'ha ancora caricato dopo l'install</td>
|
||||||
|
<td><code>sudo killall coreaudiod</code> + premi <strong>↻ Aggiorna</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>"Permesso microfono mancante" o errore ffmpeg -6</td>
|
||||||
|
<td>macOS nega l'accesso al device</td>
|
||||||
|
<td>Privacy e sicurezza → Microfono → abilita Terminal/MusicTools</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>File MP3 della registrazione è muto</td>
|
||||||
|
<td>L'output di sistema non passa da BlackHole</td>
|
||||||
|
<td>Imposta output su BlackHole o Multi-Output Device (Setup A/B)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Spotify dice "Autenticazione fallita"</td>
|
||||||
|
<td>Client ID/Secret mancanti o errati</td>
|
||||||
|
<td>Impostazioni → Spotify API → ripeti la procedura</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Instagram/Facebook non scarica</td>
|
||||||
|
<td>Contenuto privato senza cookies validi</td>
|
||||||
|
<td>Esporta cookies.txt dal browser dopo login</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Brano scaricato è sbagliato (versione cover invece dell'originale)</td>
|
||||||
|
<td>Ricerca YouTube ha preso il primo risultato</td>
|
||||||
|
<td>Modifica la query nel file di tracklist (più dettagli artista/titolo)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tag non vengono letti su un WAV</td>
|
||||||
|
<td>File usa solo chunk INFO non ID3 (raro)</td>
|
||||||
|
<td>L'app ora gestisce entrambi — controlla di avere v1.2.1+</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Stop button non risponde durante il download</td>
|
||||||
|
<td>yt-dlp sta finalizzando il file</td>
|
||||||
|
<td>Attendi qualche secondo, il processo si chiude dopo l'I/O</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Reset configurazione</h3>
|
||||||
|
<p>Se vuoi azzerare tutte le impostazioni, cancella il file <code>config.json</code>. Posizione:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>macOS</strong>: <code>~/Library/Application Support/MusicTools/config.json</code></li>
|
||||||
|
<li><strong>Windows</strong>: <code>%APPDATA%\MusicTools\config.json</code></li>
|
||||||
|
</ul>
|
||||||
|
<p>Verrà ricreato con valori di default al prossimo avvio.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="g-section">
|
||||||
|
<h2>Risorse</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload">Codice sorgente su GitHub</a></li>
|
||||||
|
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload/releases">Tutte le release</a></li>
|
||||||
|
<li><a href="#" data-ext="https://github.com/yt-dlp/yt-dlp/wiki/FAQ">FAQ di yt-dlp</a></li>
|
||||||
|
<li><a href="#" data-ext="https://existential.audio/blackhole/">BlackHole — driver loopback macOS</a></li>
|
||||||
|
</ul>
|
||||||
|
<p style="color: var(--text-3); font-size: 12px; margin-top: 20px;">MusicTools — by <strong style="color: var(--green);">LuZa</strong></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+97
-2
@@ -1,5 +1,5 @@
|
|||||||
/* ======================================================
|
/* ======================================================
|
||||||
MusicDownload — Frontend (vanilla JS)
|
MusicTools — Frontend (vanilla JS)
|
||||||
====================================================== */
|
====================================================== */
|
||||||
|
|
||||||
const $ = (sel) => document.querySelector(sel);
|
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
|
// Bridge — eventi spinti dal backend
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -340,11 +358,25 @@ $("#downloadBtn").addEventListener("click", async () => {
|
|||||||
if (!urls.length) {
|
if (!urls.length) {
|
||||||
const single = $("#urlInput").value.trim();
|
const single = $("#urlInput").value.trim();
|
||||||
if (!single) {
|
if (!single) {
|
||||||
toast("Inserisci un URL o carica una lista", "error");
|
toast("Inserisci un URL, un titolo o carica una lista", "error");
|
||||||
bridgeHandlers["download:done"]();
|
bridgeHandlers["download:done"]();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (looksLikeUrl(single)) {
|
||||||
urls = [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({
|
res = await window.pywebview.api.start_download({
|
||||||
urls,
|
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 () => {
|
$("#videoDownloadBtn").addEventListener("click", async () => {
|
||||||
const url = $("#videoUrlInput").value.trim();
|
const url = $("#videoUrlInput").value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -396,6 +439,7 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
|
|||||||
urls: [url],
|
urls: [url],
|
||||||
output_dir: state.videoOutputDir,
|
output_dir: state.videoOutputDir,
|
||||||
quality: $("#videoQuality").value,
|
quality: $("#videoQuality").value,
|
||||||
|
audio_only: videoMode === "audio",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast(res.error || "Errore", "error");
|
toast(res.error || "Errore", "error");
|
||||||
@@ -474,6 +518,57 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
$("#recRefreshBtn").addEventListener("click", refreshRecDevices);
|
$("#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 () => {
|
$("#recBrowseBtn").addEventListener("click", async () => {
|
||||||
const path = await window.pywebview.api.browse_directory();
|
const path = await window.pywebview.api.browse_directory();
|
||||||
if (path) {
|
if (path) {
|
||||||
|
|||||||
Reference in new issue
Block a user