Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:24:07 +02:00
25 changed files with 2765 additions and 240 deletions

No files matched your search

+14 -14
View File
@@ -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
+9
View File
@@ -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/
-100
View File
@@ -1,100 +0,0 @@
1. Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)
2. Brunello, Vargas – Ghost Dance (Original Mix) (6:12)
3. Hardt Antoine – Raw (Original Mix) (5:25)
4. JUNO (DE) & Samm (BE) – Heart Spin (Original Mix) (5:56)
5. AIKON – To You (Original Mix) (6:12)
6. Adam Port, Keinemusik & SG Lewis – Be The One (Extended) (4:45)
7. HotLap – Recall (Extended Mix) (5:48)
8. JUNO (DE) & Samm (BE) – Muralli (Original Mix) (4:59)
9. Brunello – Science Fiction (Original Mix) (6:36)
10. Emanuel Satie, Tim Engelhardt, Maga, Sean Doron, Unfazed, Scenarios – A Gira (Extended Remix) (5:28)
11. DJ Koze – Spiralen (Original Mix) (7:17)
12. Joris Voorn, Taylr Renee & Roddy Lima – Machine (Original Mix) (5:02)
13. KREAM & Korolova – Annihilation (Extended Mix) (4:50)
14. Bedouin, Karl Williams, LUCH – The Only One (Original Mix) (6:49)
15. Massano & Silver Panda – Pa Ca (Original Mix) (4:47)
16. Moeaike, Arodes – Nobody Else (Extended Mix) (5:58)
17. RÜFÜS DU SOL – In the Moment (Adriatique Extended Remix) (5:12)
18. &ME, Black Coffee – The Rapture Pt.III (Original Mix) (8:02)
19. Liva K – Didn’t Miss You (Original Mix) (5:46)
20. Carlo Whale & TH;EN – Echoes of You (Original Mix) (5:22)
21. Magnus, JEWELS, YUMA., SOMMA, LE YORA – EVERYTHING IN ITS RIGHT PLACE (Original Mix) (7:14)
22. Andrea Oliva – Neon Hearts (Original Mix) (6:06)
23. Jackie Hollander – High On You (Extended Mix) (4:57)
24. KREAM, SCRIPT – Turn Up The Dose (Extended Mix) (4:20)
25. JOSEPH (CH) – B4U (Original Mix) (8:00)
26. Ajna (BE), Misha – Body Moving (Original Mix) (6:16)
27. Green Velvet & Harvard Bass – Lazer Beams (Adam Beyer & Massano Remix) (5:02)
28. Argy, Omiki & Lell Nahar – MANTRA (Extended Mix) (5:03)
29. Sasha & Franky Wah – Get On My Level (Original Mix) (8:00)
30. LUCH – UH HUH (Original Mix) (6:45)
31. Fahlberg – Feeling Good (Extended Mix) (6:49)
32. Ivory (IT) – Back Again (Original Mix) (5:42)
33. Massano, Y Do I – Lost My Mind (Original Mix) (5:23)
34. KOWSKI, Eric Reyes – Break Down! (Original Mix) (5:30)
35. Andrea Oliva – Spotlight (Original Mix) (6:21)
36. Tiga vs Meduza – You Gonna Want Me (I Know) (Extended Mix) (5:21)
37. Marsh – Beside (Extended Mix) (6:18)
38. Moderat, &ME, Rampa & Keinemusik – More Love (Rampa &ME Remix) (4:55)
39. Adriatique & Emmit Fenn – Closer (Extended Mix) (4:53)
40. Jimi Jules, Black Coffee – Trippy Yeah (Extended Mix) (6:40)
41. Liva K & OVEOUS – Blessings (Original Mix) (6:35)
42. Township Rebellion, Kasablanca – It’s Chemical (Original Mix) (6:28)
43. MAXI MERAKI, Sharing-DB.club – First Time (Original Mix) (5:19)
44. Pole Position – Touch Me (Hotlap Remix) (5:40)
45. Da Hool, Cassian & YOTTO – Love Parade (Extended Mix) (4:09)
46. Jody Wisternoff, Jamie McDermott – Citadel (Extended Mix) (5:17)
47. Super Flu, Dennis Louvra – Can’t Escape (Original Mix) (5:56)
48. Armin van Buuren, Argy, Marlo Rex – Like A Child (Extended Mix) (4:08)
49. Robert Babicz – The Wave (Original Mix) (5:08)
50. The Temper Trap – Sweet Disposition (John Summit & Silver Panda Extended Remix) (5:44)
51. Yotto, Tallac – Chemtrail Surfers (Extended Mix) (6:26)
52. HotLap – Waitin’ (Extended Mix) (6:35)
53. Miss Monique, Nicolas Taboada & FRANCO BA – Ghosts (Extended Mix) (5:28)
54. Picca & Mars & Moeaike – Move in Time (Original Mix) (5:39)
55. Hardt Antoine – Sing It Back (Extended Mix) (6:57)
56. Shakedown, Layton Giordani & Anyma (ofc) – At Night (Anyma x Layton Giordani Extended Remix) (4:35)
57. Acrobatik & whatlewisdoes – Lamazi (Extended Mix) (4:46)
58. Massano – Abraza (Original Mix) (5:13)
59. Alcatraz – Giv Me Luv (Jerome Isma-Ae Remix) (7:18)
60. Gespona – Komplex (Original Mix) (5:56)
61. Misha & Martim Rola – On Business (Extended Mix) (6:30)
62. Mind Against, Anakim & HUMAN404 – Late at Night (Extended Mix) (4:39)
63. Anyma (ofc), Argy & Son of Son – Voices In My Head (Extended Mix) (4:16)
64. Fred again.., Swedish House Mafia, Future – Turn On The Lights again.. (feat. Future) (Original Mix) (4:27)
65. Sezer Uysal & Axelax – Calling For More (Original Mix) (5:12)
66. LUCH, Marten Lou – Hideaway (Extended Version) (5:57)
67. Giuseppe Ottaviani, ARTBAT & Conor Ross – Break The Loop (Original Mix) (5:22)
68. Rhodes, CamelPhat, ARTBAT – For a Feeling (Adam Sellouk & Y do I Remix – Extended) (4:49)
69. Monolink, Artbat – Return to Oz (ARTBAT Remix) (8:00)
70. Lisa & Anyma (ofc) – Bad Angel (with Lisa) (Extended Mix) (4:08)
71. Florist (BE) – Unite (Original Mix) (6:25)
72. Moderat – Eating Hooks (Deep Dish Extended Remix) (7:13)
73. CamelPhat & Arodes – Cycles (Extended Mix) (6:52)
74. Interplanetary Criminal, Kettama – Yosemite (Extended) (6:02)
75. Leblanc (ofc) – Strings Anthem (Original Mix) (5:22)
76. Mind Against & Jos & Eli – Overture (Original Mix) (5:21)
77. Tripolism & Kingstarlight – Hurricane (Extended Mix) (4:49)
78. Luke Alexander – Last Night (feat. Y4MI) (Original Mix) (3:30)
79. Braxton – There’s Still Time (Extended Mix) (6:31)
80. Township Rebellion – Birds Fly First Class (Original Mix) (6:19)
81. Agents Of Time & Miss Monique – Rajada (Original Mix) (5:00)
82. Fancy Inc – Hypnotic (Extended Mix) (5:09)
83. Boys Noize, &ME, Rampa, Adam Port, Keinemusik, Vinson – Crazy For It (feat. Vinson) (Original Mix) (6:01)
84. Space Motion & Luca Belluco – Baila Conmigo (Original Mix) (4:38)
85. Tom Westy & Dansyn – Feel Your Soul (Extended) (4:33)
86. CamelPhat & Josh Gigante – The One (Extended Mix) (5:32)
87. Argy & Omnya – Aria (Extended Mix) (5:14)
88. Air & Tur, blou., Airsand, TuraniQa – Teach Me (Extended Mix) (4:35)
89. Dilby, Martin Ka – Moving Trough (Extened Mix) (6:16)
90. Pablo Say – Repique (Original Mix) (4:48)
91. Orsay & Kotiēr – 1886 (Original Mix) (5:46)
92. Adriatique & Elderbrook – Lost In The Woods (Extended Mix) (5:01)
93. Ajna (BE) & Sentin – Bringing The Noise (Original Mix) (5:45)
94. Eric Prydz – Pjanoo (Club Mix) (7:30)
95. Eelke Kleijn – Feel It, See It (Extended Mix) (6:07)
96. Aera – Litoral (Original Mix) (6:10)
97. Julian Koerndl – Horses (Original Mix) (6:09)
98. Supermode – Tell Me Why (MEDUZA Extended Remix) (8:28)
99. Laherte – Pump Up The Jam (Extended Mix) (3:58)
100. M.O.S. – Changes (Extended Mix) (6:43)
-7
View File
@@ -1,7 +0,0 @@
https://open.spotify.com/playlist/0Q4JvzeT0q3FLsj5fqffMV?si=d17c4002f6f349c4
https://open.spotify.com/playlist/4FHKFYziOZF75AkT3aR7QP?si=b013d1cc79ff47a3
https://open.spotify.com/playlist/0mpX2EakDLmFjifuh1fQKU?si=bab58f6a7e984560
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
https://open.spotify.com/playlist/4MmZgwwHgrSs2astI4NVxI?si=021eef1a758a42fc
https://open.spotify.com/playlist/4OAx11L58sozuyNVTBi6r4?si=27d4be41e53c4e8a
-18
View File
@@ -1,18 +0,0 @@
https://open.spotify.com/playlist/5HVNSaqs3HgzIsaCVeVGrI?si=dd86ade90d2347ae
https://open.spotify.com/playlist/7ptoSkENfnO1yEQYrly725?si=4d694c3390a54ce6
https://open.spotify.com/playlist/6VjXyFH9Z5HlGPAjRBKR32?si=e8fc98ee593e4e69
https://open.spotify.com/playlist/4rdb0iJ7hZhqgBFpXHGVU6?si=96a6838af2a3487f
https://open.spotify.com/playlist/51ireDdqSrt6juqBxkMsrm?si=6c06fc88e7354b50
https://open.spotify.com/playlist/50SNkybvZnubt5uK4F2kgk?si=b7a146e4d8a54d35
https://open.spotify.com/playlist/0erLbMzXD0U9xJUPlOeKrX?si=54c87940c7be4c65
https://open.spotify.com/playlist/3FGGBSXjLGU5kfPCxL9K6f?si=77e1eee04a0745aa
https://open.spotify.com/playlist/2OxJrcgA6AQGk1sSQ8ZZ2a?si=98ede53357124322
https://open.spotify.com/playlist/345eEKyGNyluygzMsS1n1H?si=9d4be71b28e94339
https://open.spotify.com/playlist/0Umelkk6D75eZHZuxkTtLF?si=7c2f1ff3d2354abb
https://open.spotify.com/playlist/2k9QmVpMHJfyjVVvJdnRLb?si=765f62a3caf843ef
https://open.spotify.com/playlist/7xVYvU65ezCBiOu49BSV4I?si=608741585f91483d
https://open.spotify.com/playlist/1AOMaSEZf0aChEOK6L5uI3?si=e44863bf2782419b
https://open.spotify.com/playlist/3Zn4AOuMAKFCJD4VLUD0uY?si=2179f9cb5e5b4092
https://open.spotify.com/playlist/1LA5WDfX5Z4I0fQSIx4L8N?si=9918a8eadb5744b9
https://open.spotify.com/playlist/0NHDUXVEsoFMwzkrYaRHG0?si=58e61fe6319e48cb
https://open.spotify.com/playlist/2Y6PQB7k73HCrBFwiBy2Jf?si=8da4990576cd444f
+92 -32
View File
@@ -1,6 +1,6 @@
# MusicDownload # MusicTools
Applicazione desktop per scaricare **musica** da Spotify, YouTube e SoundCloud, e **video** da YouTube, TikTok, Instagram e Facebook. Include anche un upgrader di qualità per migliorare i file audio già scaricati. Applicazione desktop per **scaricare musica e video**, **modificare i metadati** dei file audio e **registrare l'audio** da qualsiasi ingresso del computer.
UI moderna basata su **pywebview** (WebKit su macOS, EdgeChromium su Windows) — frontend HTML/CSS/JS, backend Python. UI moderna basata su **pywebview** (WebKit su macOS, EdgeChromium su Windows) — frontend HTML/CSS/JS, backend Python.
@@ -8,14 +8,26 @@ Sviluppata da **LuZa**.
Repo: <https://github.com/luzadev/musicdownload> Repo: <https://github.com/luzadev/musicdownload>
> ℹ️ Il repo Git conserva il nome storico **`musicdownload`** (l'app prima si chiamava così); URL stabili per release già pubblicate e installazioni esistenti.
## Funzionalità ## Funzionalità
- **Scarica musica** — Incolla un link Spotify (playlist/album/brano), YouTube, SoundCloud o qualsiasi sorgente yt-dlp e scarica in MP3 a 320 kbps. Supporta liste di URL da file `.txt`. | Tab | Cosa fa |
- **Scarica video** — Incolla un link **YouTube · TikTok · Instagram · Facebook** (o qualsiasi sorgente yt-dlp) e scarica in MP4 alla qualità scelta (best / 1080p / 720p / 480p). |---|---|
- **Upgrade qualità** — Scansiona una cartella di file audio e riscarica quelli sotto la soglia kbps impostata. | **⬇ Scarica** | Spotify (playlist/album/brano) · YouTube · SoundCloud · lista URL `.txt` · tracklist `.txt` con titoli (es. Beatport Top 100) |
- **Auto-skip dei duplicati** — Tracking persistente (`.downloaded_tracks` / `.downloaded_videos`) + scan filename token-based: re-eseguire la stessa playlist salta i brani già presenti, anche se il filename ha "Official Video" / "Lyrics" / ecc. | **🎬 Video** | YouTube · TikTok · Instagram · Facebook (e 1000+ siti yt-dlp) in MP4, qualità best/1080p/720p/480p |
- **Aggiornamento automatico** — Verifica disponibilità nuove versioni dalla pagina Impostazioni. | **⚡ Upgrade** | Scansiona una cartella audio e riscarica i file sotto la soglia kbps impostata |
- **Lista multi-URL** — Carica un file `.txt` con un URL per riga per scaricare più playlist in sequenza. | **🏷 Metadati** | Editor tag ID3 / MP4 / Vorbis (MP3, M4A, FLAC, WAV) — titolo, artista, album, anno, copertina, BPM, key, commento, origine (macOS) |
| **● Registra** | Cattura audio in MP3 320 kbps da qualsiasi ingresso (mixer, scheda audio, microfono, BlackHole/loopback) |
| **⚙ Impostazioni** | Credenziali Spotify, bitrate, soglia HQ, percorsi, tema, controllo aggiornamenti |
### Auto-skip dei duplicati
Tracking persistente (`.downloaded_tracks` / `.downloaded_videos`) + scan filename token-based: re-eseguire la stessa playlist salta i brani già presenti, anche se il filename ha `Official Video` / `Lyrics` / ecc.
### Sottocartella automatica per le tracklist
Se carichi un file `.txt` con titoli (es. `Beatport Top 100 Melodic House & Techno.txt`), l'app crea automaticamente una sottocartella `<output_dir>/Beatport Top 100 Melodic House & Techno/` per non mischiare brani di playlist diverse.
## Requisiti ## Requisiti
@@ -39,8 +51,9 @@ Dipendenze principali:
| Pacchetto | Utilizzo | | Pacchetto | Utilizzo |
|---|---| |---|---|
| `pywebview` | Finestra nativa con WebView (WebKit / EdgeChromium) | | `pywebview` | Finestra nativa con WebView (WebKit / EdgeChromium) |
| `requests` | Chiamate API Spotify | | `requests` | Chiamate API Spotify e GitHub |
| `yt-dlp` | Download audio/video da YouTube e oltre 1000 siti | | `yt-dlp` | Download audio/video da YouTube e oltre 1000 siti |
| `mutagen` | Lettura/scrittura tag ID3, MP4, FLAC, WAV |
| `pyobjc-framework-WebKit` (macOS) | Backend WebKit di pywebview | | `pyobjc-framework-WebKit` (macOS) | Backend WebKit di pywebview |
| `pythonnet` (Windows) | Backend EdgeChromium di pywebview | | `pythonnet` (Windows) | Backend EdgeChromium di pywebview |
@@ -60,10 +73,63 @@ Le credenziali sono gratuite, non servono Spotify Premium.
Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTube serve un file `cookies.txt` (formato Netscape). Esportalo dal browser con un'estensione tipo "Get cookies.txt LOCALLY" e imposta il percorso in **Impostazioni → Percorsi**. Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTube serve un file `cookies.txt` (formato Netscape). Esportalo dal browser con un'estensione tipo "Get cookies.txt LOCALLY" e imposta il percorso in **Impostazioni → Percorsi**.
## Guida rapida: registrazione audio di sistema (macOS)
macOS non espone nativamente l'output audio del sistema. Per registrare quello che esce dagli speaker (es. uno stream Spotify/YouTube), serve un driver virtuale di **loopback**. Il più diffuso è **BlackHole**, gratuito e open source.
### 1. Installazione
```bash
brew install blackhole-2ch
```
> Subito dopo l'installazione, se non vedi BlackHole nella tab Registra, riavvia il servizio audio:
> ```bash
> sudo killall coreaudiod
> ```
> Si rilancia da solo in pochi secondi, nessun danno. In alternativa, logout/login o riavvio del Mac.
### 2. Permesso Microfono
Anche se non è un microfono fisico, macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation.
Vai su **Preferenze di Sistema → Privacy e sicurezza → Microfono** e abilita l'app da cui lanci MusicTools (Terminal/iTerm in sviluppo, oppure MusicTools.app per la build distribuita). Se non vedi nessuna voce, comparirà un popup la prima volta che premi REC — accetta.
### 3. Setup A — minimo (registri ma non senti l'audio durante)
1. Click sull'icona altoparlante nella barra menu → **Output → BlackHole 2ch**
*Oppure: Preferenze di Sistema → Audio → Uscita → BlackHole 2ch*
2. Fai partire lo stream (Spotify/YouTube): non sentirai nulla — è normale, l'audio va a BlackHole
3. App MusicTools → tab **● Registra** → dispositivo **🔄 BlackHole 2ch (loopback)** → **REC**
4. **Stop** e riascolta il file MP3 generato
### 4. Setup B — completo (registri E senti l'audio contemporaneamente, consigliato)
1. Apri **Configurazione MIDI Audio** (`Applicazioni → Utility → Configurazione MIDI Audio`)
2. In basso a sinistra: **+ → Crea dispositivo aggregato a uscita multipla** (Multi-Output Device)
3. Nella colonna a destra, spunta sia **BlackHole 2ch** sia i tuoi **Altoparlanti integrati** (o le cuffie)
4. Imposta **BlackHole 2ch** come **Master Device** (menù a tendina in alto)
5. Abilita **Drift Correction** sulla riga degli altoparlanti
6. (Opzionale) Rinomina il dispositivo aggregato in `Speakers + BlackHole`
7. **Preferenze di Sistema → Audio → Uscita** → seleziona `Speakers + BlackHole`
8. Nella tab Registra dell'app scegli **🔄 BlackHole 2ch (loopback)** — **NON il dispositivo aggregato**, quello è solo per riprodurre
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
### Troubleshooting
| Sintomo | Causa probabile | Fix |
|---|---|---|
| BlackHole non compare nel dropdown | CoreAudio non l'ha ancora caricato dopo l'install | `sudo killall coreaudiod` poi premi **↻ Aggiorna** |
| Errore `ffmpeg exit -6` o `permesso Microfono mancante` | macOS nega l'accesso al device | Privacy e sicurezza → Microfono → abilita Terminal/MusicTools |
| File MP3 muto | Output di sistema non passa per BlackHole | Cambia output su BlackHole o Multi-Output Device (vedi Setup A/B) |
| Audio doppio/eco nel file | Stai registrando da BlackHole *e* da microfono fisico simultaneamente | Verifica che nel dropdown ci sia solo `BlackHole 2ch` |
| Audio crackly/distorto | Sample rate non allineato | In Audio MIDI Setup, BlackHole + altoparlanti tutti a 48 kHz |
## Struttura progetto ## Struttura progetto
``` ```
MusicDownload/ MusicTools/
├── main.py # Entry point — bootstrap pywebview ├── main.py # Entry point — bootstrap pywebview
├── requirements.txt ├── requirements.txt
├── build_macos.py # Script build macOS (.app) ├── build_macos.py # Script build macOS (.app)
@@ -75,14 +141,16 @@ MusicDownload/
│ ├── config.py # Configurazione persistente (JSON) │ ├── config.py # Configurazione persistente (JSON)
│ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg) │ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg)
│ ├── spotify_client.py # API Spotify (Client Credentials) │ ├── spotify_client.py # API Spotify (Client Credentials)
│ ├── downloader.py # Download audio (playlist Spotify, URL diretti, video) │ ├── downloader.py # Download audio + video (Spotify, yt-dlp diretto)
│ └── upgrader.py # Upgrade qualità file esistenti │ ├── upgrader.py # Upgrade qualità file esistenti
│ ├── metadata.py # Lettura/scrittura tag ID3/MP4/Vorbis/WAV
│ └── recorder.py # Registrazione audio via ffmpeg
│ │
├── api/ ├── api/
│ └── bridge.py # Ponte JS <-> Python (pywebview.js_api) │ └── bridge.py # Ponte JS <-> Python (pywebview.js_api)
│ │
└── webui/ # Frontend └── webui/ # Frontend
├── index.html # Single-page, 4 view (Scarica / Video / Upgrade / Impostazioni) ├── index.html # Single-page, 6 view
├── css/style.css # Tema scuro Spotify-like, gradient hero, animazioni ├── css/style.css # Tema scuro Spotify-like, gradient hero, animazioni
└── js/app.js # Routing, eventi, chiamate API └── js/app.js # Routing, eventi, chiamate API
``` ```
@@ -103,8 +171,8 @@ Il file `config.json` viene creato automaticamente al primo avvio:
Posizione: Posizione:
- **Sviluppo**: directory del progetto - **Sviluppo**: directory del progetto
- **macOS bundle**: `~/Library/Application Support/MusicDownload/` - **macOS bundle**: `~/Library/Application Support/MusicTools/`
- **Windows bundle**: `%APPDATA%/MusicDownload/` - **Windows bundle**: `%APPDATA%/MusicTools/`
## Build locale ## Build locale
@@ -116,7 +184,7 @@ pip install pyinstaller
python3 build_macos.py python3 build_macos.py
``` ```
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`). Produce `dist/MusicTools.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
### Windows ### Windows
@@ -125,44 +193,36 @@ pip install pyinstaller
python build_windows.py python build_windows.py
``` ```
Produce `dist/MusicDownload/MusicDownload.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build. Produce `dist/MusicTools/MusicTools.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build.
## Build & Release automatiche (GitHub Actions) ## Build & Release automatiche (GitHub Actions)
Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`: Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`:
1. **`build-macos`** (runner `macos-latest`) → `MusicDownload-macOS.zip` (la `.app` zippata con `ditto`) 1. **`build-macos`** (runner `macos-latest`) → `MusicTools-macOS.zip` (la `.app` zippata con `ditto`)
2. **`build-windows`** (runner `windows-latest`) → `MusicDownload-Windows.zip` 2. **`build-windows`** (runner `windows-latest`) → `MusicTools-Windows.zip`
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati 3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
Per pubblicare una nuova versione: Per pubblicare una nuova versione:
```bash ```bash
git tag v1.2.0 git tag v1.3.0
git push origin v1.2.0 git push origin v1.3.0
``` ```
Per fare solo una build di test (senza release): Per fare solo una build di test (senza release):
```bash ```bash
gh workflow run "Build MusicDownload" --ref main gh workflow run "Build MusicTools" --ref main
``` ```
oppure dalla UI: **Actions → Build MusicDownload → Run workflow**. oppure dalla UI: **Actions → Build MusicTools → Run workflow**.
Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>. Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>.
## Aggiornamento app ## Aggiornamento app
L'app verifica nuove versioni interrogando un `version.json` remoto. URL configurato in `api/bridge.py` (`UPDATE_URL` nel metodo `check_update`): L'app verifica nuove versioni interrogando l'API GitHub Releases (`api/bridge.py` → `check_update`). Per funzionare end-to-end serve che il repo sia pubblico, altrimenti l'API restituisce 404.
```json
{
"version": "v1.2.0",
"download_url": "https://github.com/luzadev/musicdownload/releases/latest",
"notes": "Descrizione delle novità"
}
```
## Licenza ## Licenza
-5
View File
@@ -1,5 +0,0 @@
https://open.spotify.com/intl-it/album/3lgCqbQpmLscSRuWVMT5wS?si=UPiqK8smRgO_1JAAiZTrYg
https://open.spotify.com/intl-it/album/3pGb3m5KA1Y3gCVT6leCL3?si=Cj_OrTE5QAiEnwMD63EunA
https://open.spotify.com/intl-it/album/2ewhdpODXqoB0fXIKfq4u2?si=e2N7jWHiTWGiudHHMJNHHQ
https://open.spotify.com/intl-it/album/2xGfPfmx6faZUr9IaKEiSG?si=2Q-qMwWCTp-Yp7RIfhE2Ig
https://open.spotify.com/intl-it/album/43pKffJweYUVwEYcHPIfox?si=2E8IoUmOQp-cgsCujVBPMQ
+127 -14
View File
@@ -20,6 +20,13 @@ from core.downloader import (
is_stopped as download_is_stopped, is_stopped as download_is_stopped,
) )
from core.spotify_client import get_access_token, resolve_spotify_url from core.spotify_client import get_access_token, resolve_spotify_url
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
from core.recorder import (
list_input_devices,
start_recording,
stop_recording as recorder_stop,
is_recording,
)
from core.upgrader import ( from core.upgrader import (
upgrade_folder, upgrade_folder,
request_stop as request_upgrade_stop, request_stop as request_upgrade_stop,
@@ -31,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"
@@ -134,22 +141,43 @@ class Api:
return {"ok": True} return {"ok": True}
def check_update(self) -> dict: def check_update(self) -> dict:
"""Controlla aggiornamenti chiamando version.json remoto.""" """Controlla aggiornamenti interrogando l'API GitHub Releases."""
import urllib.request import platform
import requests
UPDATE_URL = "https://www.djluza.com/musicdownload/version.json" API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
try: try:
req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "MusicDownload"}) resp = requests.get(API_URL, headers={
with urllib.request.urlopen(req, timeout=10) as resp: "User-Agent": "MusicTools",
data = json.loads(resp.read().decode()) "Accept": "application/vnd.github+json",
remote = data.get("version", "") }, timeout=10)
resp.raise_for_status()
data = resp.json()
remote = data.get("tag_name", "")
notes = data.get("body", "") or ""
html_url = data.get("html_url", "")
# Trova l'asset giusto per la piattaforma
assets = data.get("assets", []) or []
is_macos = platform.system() == "Darwin"
keyword = "macos" if is_macos else "windows"
download_url = ""
for a in assets:
name = (a.get("name") or "").lower()
if keyword in name and name.endswith(".zip"):
download_url = a.get("browser_download_url", "")
break
if not download_url:
download_url = html_url # fallback: pagina release
return { return {
"ok": True, "ok": True,
"current": VERSION, "current": VERSION,
"remote": remote, "remote": remote,
"is_new": bool(remote and remote != VERSION), "is_new": bool(remote and remote != VERSION),
"download_url": data.get("download_url", ""), "download_url": download_url,
"notes": data.get("notes", ""), "notes": notes[:500] if notes else "", # tronca per UI
} }
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e), "current": VERSION} return {"ok": False, "error": str(e), "current": VERSION}
@@ -221,6 +249,86 @@ class Api:
def open_external_url(self, url: str) -> None: def open_external_url(self, url: str) -> None:
webbrowser.open(url) webbrowser.open(url)
# ------------------------------------------------------------------
# Metadata editor
# ------------------------------------------------------------------
def pick_audio_file(self) -> str:
return self.browse_file([
"Audio (*.mp3;*.m4a;*.mp4;*.aac;*.flac;*.wav)",
"All files (*.*)",
])
def pick_image_file(self) -> str:
return self.browse_file([
"Immagini (*.jpg;*.jpeg;*.png)",
"All files (*.*)",
])
def read_metadata(self, path: str) -> dict:
try:
data = read_metadata(path)
return {"ok": True, "data": data}
except Exception as e:
return {"ok": False, "error": str(e)}
def save_metadata(self, payload: dict) -> dict:
path = (payload.get("path") or "").strip()
if not path:
return {"ok": False, "error": "Path mancante"}
if not os.path.exists(path):
return {"ok": False, "error": "File non trovato"}
data = payload.get("data") or {}
cover_path = (payload.get("cover_path") or "").strip() or None
remove_cover = bool(payload.get("remove_cover", False))
try:
write_metadata(path, data, cover_path, remove_cover)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
# ------------------------------------------------------------------
# Recorder
# ------------------------------------------------------------------
def list_audio_inputs(self) -> dict:
try:
return {"ok": True, "devices": list_input_devices()}
except Exception as e:
return {"ok": False, "error": str(e), "devices": []}
def start_audio_recording(self, payload: dict) -> dict:
device_id = str(payload.get("device_id", "")).strip()
output_dir = (payload.get("output_dir") or "").strip()
filename = (payload.get("filename") or "").strip()
bitrate = (payload.get("bitrate") or "320k").strip()
if not device_id:
return {"ok": False, "error": "Dispositivo non selezionato"}
if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"}
if not filename:
filename = f"Registrazione_{time.strftime('%Y%m%d_%H%M%S')}.mp3"
if not filename.lower().endswith(".mp3"):
filename += ".mp3"
out_path = os.path.join(output_dir, filename)
def cb(status, payload_evt):
payload_evt = dict(payload_evt or {})
payload_evt["status"] = status
self._emit("recording:event", payload_evt)
res = start_recording(device_id, out_path, bitrate=bitrate,
progress_callback=cb)
return res
def stop_audio_recording(self) -> dict:
return recorder_stop()
def is_audio_recording(self) -> dict:
return {"recording": is_recording()}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Audio folder scan (Upgrade tab) # Audio folder scan (Upgrade tab)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -532,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"}
@@ -540,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()
@@ -551,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"
@@ -610,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
+234
View File
@@ -0,0 +1,234 @@
"""Genera l'icona dell'app (PNG/ICNS/ICO) in modo riproducibile.
Stile: squircle (Apple-style rounded rect) con gradient verde Spotify
verso nero, nota musicale bianca al centro con leggero glow, highlight
sottile in alto.
Uso:
python3 assets/generate_icon.py
Produce:
assets/icon.png (1024x1024, master)
assets/icon.icns (macOS, generato via iconutil)
assets/icon.ico (Windows, multi-size)
assets/icon.iconset/... (intermedio macOS)
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Tuple
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
SIZE = 1024
RADIUS_RATIO = 0.225 # ~22% del lato, Apple iOS-like squircle
PADDING_RATIO = 0.085 # margine fra bordo immagine e squircle
# Palette (coerente col tema della UI)
GREEN_TOP = (30, 215, 96) # verde Spotify lucido
GREEN_BOTTOM = (15, 59, 34) # verde scuro profondo
WHITE = (255, 255, 255, 255)
HIGHLIGHT = (255, 255, 255, 38) # bianco semitrasparente per il riflesso
def vertical_gradient(size: int, top: Tuple[int, int, int],
bottom: Tuple[int, int, int]) -> Image.Image:
"""Crea un gradient verticale top -> bottom."""
img = Image.new("RGB", (size, size), top)
px = img.load()
for y in range(size):
t = y / (size - 1)
# ease (quad) per ammorbidire la transizione
t = t * t
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(size):
px[x, y] = (r, g, b)
return img
def squircle_mask(size: int, radius: int, padding: int) -> Image.Image:
"""Maschera alpha per un rounded rect centrato con padding."""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle(
(padding, padding, size - padding, size - padding),
radius=radius, fill=255,
)
return mask
def find_music_font() -> str:
"""Trova un font che contenga il glifo della nota musicale (♫)."""
candidates = [
"/System/Library/Fonts/Apple Symbols.ttf",
"/System/Library/Fonts/Symbol.ttf",
"/System/Library/Fonts/SFNS.ttf",
"/Library/Fonts/Arial Unicode.ttf",
]
for f in candidates:
if Path(f).exists():
return f
raise RuntimeError("Nessun font Unicode con simboli trovato")
def draw_note(img: Image.Image, glyph: str = "♫") -> None:
"""Stampa la nota musicale centrata, in bianco con leggero glow."""
font_path = find_music_font()
target_pt = int(SIZE * 0.62)
font = ImageFont.truetype(font_path, target_pt)
cx, cy = SIZE // 2, SIZE // 2 - int(SIZE * 0.01)
# Glow: stessa nota disegnata sfocata sotto in bianco semitrasparente
glow_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
glow_draw = ImageDraw.Draw(glow_layer)
glow_draw.text((cx, cy), glyph, font=font, fill=(255, 255, 255, 110),
anchor="mm")
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=24))
img.alpha_composite(glow_layer)
# Testo nitido sopra
draw = ImageDraw.Draw(img)
draw.text((cx, cy), glyph, font=font, fill=WHITE, anchor="mm")
def make_master() -> Image.Image:
"""Genera l'immagine master 1024x1024 RGBA con tutto."""
padding = int(SIZE * PADDING_RATIO)
radius = int(SIZE * RADIUS_RATIO)
# Background gradient verde
gradient = vertical_gradient(SIZE, GREEN_TOP, GREEN_BOTTOM)
# Squircle: prendiamo il gradient mascherato come forma
mask = squircle_mask(SIZE, radius, padding)
base = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
base.paste(gradient, (0, 0), mask)
# Highlight in alto: banda sottile (tipo "shine" iOS), molto trasparente
hl_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
hl_draw = ImageDraw.Draw(hl_layer)
hl_top = padding + int(SIZE * 0.04)
hl_bot = padding + int(SIZE * 0.16)
hl_left = padding + int(SIZE * 0.10)
hl_right = SIZE - padding - int(SIZE * 0.10)
hl_draw.ellipse(
(hl_left, hl_top, hl_right, hl_bot),
fill=(255, 255, 255, 30),
)
hl_layer = hl_layer.filter(ImageFilter.GaussianBlur(radius=18))
masked = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
masked.paste(hl_layer, (0, 0), mask)
base.alpha_composite(masked)
# Bordo interno sottile per dare definizione (stroke da 0.5%)
ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
ring_draw = ImageDraw.Draw(ring)
ring_draw.rounded_rectangle(
(padding, padding, SIZE - padding, SIZE - padding),
radius=radius,
outline=(255, 255, 255, 28),
width=max(2, int(SIZE * 0.004)),
)
base.alpha_composite(ring)
# Nota musicale al centro
draw_note(base)
# Lieve ombra interna in basso per profondita (vignette)
shade = Image.new("L", (SIZE, SIZE), 0)
sd = ImageDraw.Draw(shade)
sd.rounded_rectangle(
(padding, padding, SIZE - padding, SIZE - padding),
radius=radius, outline=255, width=int(SIZE * 0.025),
)
shade = shade.filter(ImageFilter.GaussianBlur(radius=int(SIZE * 0.025)))
overlay = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
overlay.paste((0, 0, 0, 60), mask=shade)
overlay = Image.new("RGBA", overlay.size, (0, 0, 0, 0)).convert("RGBA")
# Skip vignette complicata — base e' gia' coerente
return base
def export_iconset(master: Image.Image, outdir: Path) -> None:
"""Esporta le dimensioni standard macOS in outdir."""
outdir.mkdir(parents=True, exist_ok=True)
sizes = [
(16, "icon_16x16.png"),
(32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"),
(64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"),
(256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"),
(512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"),
(1024, "icon_512x512@2x.png"),
]
for px, name in sizes:
img = master.resize((px, px), Image.LANCZOS)
img.save(outdir / name, "PNG")
def build_icns(iconset_dir: Path, output_icns: Path) -> bool:
"""Usa iconutil di macOS per generare il .icns. Ritorna True se ok."""
if not shutil.which("iconutil"):
return False
try:
subprocess.run(
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(output_icns)],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def build_ico(master: Image.Image, output_ico: Path) -> None:
"""Genera il .ico multi-size per Windows."""
sizes = [(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)]
master.save(output_ico, format="ICO",
sizes=sizes, append_images=[])
def main():
print(f">>> Genero icona master {SIZE}x{SIZE}...")
master = make_master()
master_path = HERE / "icon.png"
master.save(master_path, "PNG")
print(f" {master_path}")
iconset_dir = HERE / "icon.iconset"
if iconset_dir.exists():
shutil.rmtree(iconset_dir)
print(">>> Esporto iconset macOS...")
export_iconset(master, iconset_dir)
icns_path = HERE / "icon.icns"
if build_icns(iconset_dir, icns_path):
print(f" {icns_path}")
else:
print(" SKIP icns (iconutil non disponibile)")
print(">>> Genero icon.ico per Windows...")
ico_path = HERE / "icon.ico"
build_ico(master, ico_path)
print(f" {ico_path}")
print("\nDone.")
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+16 -10
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Build script per creare MusicDownload.app su macOS. Build script per creare MusicTools.app su macOS.
Uso: Uso:
python3 build_macos.py python3 build_macos.py
@@ -8,7 +8,7 @@ Uso:
Cosa fa: Cosa fa:
1. Scarica il binario standalone di yt-dlp per macOS 1. Scarica il binario standalone di yt-dlp per macOS
2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew 2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew
3. Esegue PyInstaller per creare MusicDownload.app 3. Esegue PyInstaller per creare MusicTools.app
Requisiti: Requisiti:
pip3 install pyinstaller pip3 install pyinstaller
@@ -208,28 +208,34 @@ def run_pyinstaller():
if f.is_file(): if f.is_file():
add_binaries.extend(["--add-binary", f"{f}:lib"]) add_binaries.extend(["--add-binary", f"{f}:lib"])
icon_path = ROOT / "assets" / "icon.icns"
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--name", "MusicDownload", "--name", "MusicTools",
"--windowed", "--windowed",
"--onedir", "--onedir",
"--noconfirm", "--noconfirm",
]
if icon_path.exists():
cmd.extend(["--icon", str(icon_path)])
cmd.extend([
# Web UI assets (HTML/CSS/JS) — usati da pywebview # Web UI assets (HTML/CSS/JS) — usati da pywebview
"--add-data", f"{ROOT / 'webui'}:webui", "--add-data", f"{ROOT / 'webui'}:webui",
# Hidden imports per pywebview (WebKit su macOS) # Hidden imports per pywebview (WebKit su macOS)
"--hidden-import", "webview", "--hidden-import", "webview",
"--hidden-import", "webview.platforms.cocoa", "--hidden-import", "webview.platforms.cocoa",
"--hidden-import", "requests", "--hidden-import", "requests",
"--collect-all", "mutagen",
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs) # Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
*add_binaries, *add_binaries,
# Entry point # Entry point
str(ROOT / "main.py"), str(ROOT / "main.py"),
] ])
log(f"Comando: {' '.join(cmd[:10])}...") log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT)) subprocess.run(cmd, check=True, cwd=str(ROOT))
app_path = DIST_DIR / "MusicDownload.app" app_path = DIST_DIR / "MusicTools.app"
if app_path.exists(): if app_path.exists():
log(f"Build completata: {app_path}") log(f"Build completata: {app_path}")
# Mostra dimensione # Mostra dimensione
@@ -237,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}")
@@ -248,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
@@ -271,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()
+16 -12
View File
@@ -1,5 +1,5 @@
""" """
Build script per creare MusicDownload.exe su Windows. Build script per creare MusicTools.exe su Windows.
Uso: Uso:
python build_windows.py python build_windows.py
@@ -7,7 +7,7 @@ Uso:
Cosa fa: Cosa fa:
1. Scarica il binario standalone di yt-dlp per Windows 1. Scarica il binario standalone di yt-dlp per Windows
2. Scarica ffmpeg static build per Windows 2. Scarica ffmpeg static build per Windows
3. Esegue PyInstaller per creare MusicDownload.exe 3. Esegue PyInstaller per creare MusicTools.exe
Requisiti: Requisiti:
pip install pyinstaller pip install pyinstaller
@@ -99,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)
@@ -116,20 +119,21 @@ def run_pyinstaller():
"--hidden-import", "requests", "--hidden-import", "requests",
# Collegamenti completi alle DLL .NET di pywebview/pythonnet # Collegamenti completi alle DLL .NET di pywebview/pythonnet
"--collect-all", "webview", "--collect-all", "webview",
"--collect-all", "mutagen",
# Binari bundled # Binari bundled
*add_binaries, *add_binaries,
# Entry point # Entry point
str(ROOT / "main.py"), str(ROOT / "main.py"),
] ])
log(f"Comando: {' '.join(cmd[:10])}...") log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT)) subprocess.run(cmd, check=True, cwd=str(ROOT))
exe_path = DIST_DIR / "MusicDownload" / "MusicDownload.exe" exe_path = DIST_DIR / "MusicTools" / "MusicTools.exe"
if exe_path.exists(): if exe_path.exists():
log(f"Build completata: {exe_path}") log(f"Build completata: {exe_path}")
else: else:
log("ATTENZIONE: MusicDownload.exe non trovato, controlla l'output") log("ATTENZIONE: MusicTools.exe non trovato, controlla l'output")
# ========================================================================= # =========================================================================
@@ -137,7 +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":
@@ -166,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
View File
@@ -5,24 +5,42 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.0" 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
View File
@@ -9,7 +9,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
# Flag globale per interruzione # Flag globale per interruzione
@@ -102,7 +102,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}") raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
@@ -146,8 +146,10 @@ def download_playlist(
progress_callback(i, total, "", "stopped", 0) progress_callback(i, total, "", "stopped", 0)
return return
query = f"{track['name']} - {track['artist']}" artist = (track.get('artist') or "").strip()
track_only = track['name'] name = (track.get('name') or "").strip()
query = f"{name} - {artist}" if artist else name
track_only = name
# Salta brani gia scaricati in precedenza (file di tracking) # Salta brani gia scaricati in precedenza (file di tracking)
if query in done_set: if query in done_set:
@@ -204,6 +206,7 @@ def download_playlist(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
@@ -278,7 +281,7 @@ def download_direct_url(
probe_cmd.extend(["--cookies", cookies_path]) probe_cmd.extend(["--cookies", cookies_path])
try: try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60) result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(result.stderr.strip()) raise ValueError(result.stderr.strip())
except Exception as e: except Exception as e:
@@ -356,6 +359,7 @@ def download_direct_url(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
@@ -418,10 +422,14 @@ def download_video(
quality: str = "1080p", quality: str = "1080p",
cookies_path: Optional[str] = None, cookies_path: Optional[str] = None,
progress_callback: Optional[Callable] = None, progress_callback: Optional[Callable] = None,
audio_only: bool = False,
audio_bitrate: str = "320K",
) -> None: ) -> None:
"""Scarica video (con audio) da YouTube/TikTok/Instagram/Facebook/etc. """Scarica video (con audio) o solo audio da YouTube/TikTok/Instagram/Facebook/etc.
quality: "best" | "1080p" | "720p" | "480p" quality: "best" | "1080p" | "720p" | "480p" (ignorato se audio_only=True)
audio_only: True -> estrae solo audio in MP3
audio_bitrate: bitrate MP3 quando audio_only=True
Per Instagram/Facebook privati servono cookies validi. Per Instagram/Facebook privati servono cookies validi.
""" """
global _current_process global _current_process
@@ -439,7 +447,7 @@ def download_video(
probe_cmd.extend(["--cookies", cookies_path]) probe_cmd.extend(["--cookies", cookies_path])
try: try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60) result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(result.stderr.strip()) raise ValueError(result.stderr.strip())
except Exception as e: except Exception as e:
@@ -461,7 +469,7 @@ def download_video(
return return
total = len(entries) total = len(entries)
fmt = _video_format(quality) fmt = _video_format(quality) if not audio_only else "bestaudio/best"
for i, entry in enumerate(entries): for i, entry in enumerate(entries):
if is_stopped(): if is_stopped():
@@ -483,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:
+520
View File
@@ -0,0 +1,520 @@
"""Lettura e scrittura metadati audio (ID3 per MP3, MP4 per M4A, Vorbis per FLAC)."""
from __future__ import annotations
import base64
import plistlib
import subprocess
import struct
import sys
from pathlib import Path
from typing import Optional
from mutagen import File as MutagenFile
from mutagen.id3 import (
ID3, ID3NoHeaderError,
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
)
from mutagen.mp4 import MP4, MP4Cover
from mutagen.flac import FLAC, Picture
from mutagen.wave import WAVE
SUPPORTED_EXTS = (".mp3", ".m4a", ".mp4", ".aac", ".flac", ".wav")
_WHERE_FROM_ATTR = "com.apple.metadata:kMDItemWhereFroms"
def _read_where_from(path: str) -> list:
"""Legge l'xattr macOS 'kMDItemWhereFroms' (lista di URL/origini).
Ritorna [] se non e macOS o se l'attributo non esiste."""
if sys.platform != "darwin":
return []
try:
res = subprocess.run(
["xattr", "-px", _WHERE_FROM_ATTR, path],
capture_output=True, text=True, timeout=5,
)
if res.returncode != 0:
return []
hex_str = "".join(res.stdout.split())
if not hex_str:
return []
raw = bytes.fromhex(hex_str)
items = plistlib.loads(raw)
if isinstance(items, list):
return [str(x) for x in items]
return [str(items)]
except Exception:
return []
def _write_where_from(path: str, urls: list) -> None:
"""Scrive l'xattr macOS 'kMDItemWhereFroms'. Lista vuota -> rimuove."""
if sys.platform != "darwin":
return
try:
clean = [u.strip() for u in (urls or []) if u and u.strip()]
if not clean:
subprocess.run(
["xattr", "-d", _WHERE_FROM_ATTR, path],
capture_output=True, timeout=5,
)
return
data = plistlib.dumps(clean, fmt=plistlib.FMT_BINARY)
subprocess.run(
["xattr", "-wx", _WHERE_FROM_ATTR, data.hex(), path],
capture_output=True, timeout=5, check=True,
)
except Exception:
pass
def _text(value) -> str:
"""Estrai testo da un frame mutagen."""
if value is None:
return ""
if hasattr(value, "text"):
t = value.text
if isinstance(t, list) and t:
return str(t[0])
return str(t)
if isinstance(value, list) and value:
return str(value[0])
return str(value)
def _read_mp3(path: str, result: dict) -> dict:
result["format"] = "MP3"
try:
tags = ID3(path)
except ID3NoHeaderError:
return result
result["title"] = _text(tags.get("TIT2"))
result["artist"] = _text(tags.get("TPE1"))
result["album_artist"] = _text(tags.get("TPE2"))
result["album"] = _text(tags.get("TALB"))
result["year"] = _text(tags.get("TDRC"))
result["track"] = _text(tags.get("TRCK"))
result["genre"] = _text(tags.get("TCON"))
result["bpm"] = _text(tags.get("TBPM"))
result["key"] = _text(tags.get("TKEY"))
for k in tags.keys():
if k.startswith("COMM"):
result["comment"] = _text(tags[k])
break
for k in tags.keys():
if k.startswith("APIC"):
apic = tags[k]
result["cover_base64"] = base64.b64encode(apic.data).decode("ascii")
result["cover_mime"] = apic.mime or "image/jpeg"
break
return result
def _read_mp4(path: str, result: dict) -> dict:
result["format"] = "M4A/MP4"
m = MP4(path)
tags = m.tags or {}
result["title"] = _text(tags.get("\xa9nam"))
result["artist"] = _text(tags.get("\xa9ART"))
result["album_artist"] = _text(tags.get("aART"))
result["album"] = _text(tags.get("\xa9alb"))
result["year"] = _text(tags.get("\xa9day"))
track_pair = tags.get("trkn")
if track_pair and isinstance(track_pair, list) and track_pair:
num, tot = track_pair[0] if isinstance(track_pair[0], tuple) else (track_pair[0], 0)
result["track"] = f"{num}/{tot}" if tot else str(num)
result["genre"] = _text(tags.get("\xa9gen"))
result["comment"] = _text(tags.get("\xa9cmt"))
result["bpm"] = _text(tags.get("tmpo"))
covers = tags.get("covr")
if covers:
c = covers[0]
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png"
return result
_WAV_INFO_MAP = {
"INAM": "title",
"IART": "artist",
"IPRD": "album",
"ICRD": "year",
"IYER": "year",
"IGNR": "genre",
"ICMT": "comment",
"ITRK": "track",
"IPRT": "track",
"TRCK": "track",
"IBPM": "bpm",
"TBPM": "bpm",
"TKEY": "key",
}
def _read_wav_info_chunk(path: str) -> dict:
"""Legge il chunk RIFF LIST/INFO di un file WAV.
Ritorna un dict con i campi noti. Usato come fallback quando
il WAV non contiene ID3 embedded."""
out: dict = {}
try:
with open(path, "rb") as f:
header = f.read(12)
if len(header) < 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE":
return out
while True:
hdr = f.read(8)
if len(hdr) < 8:
break
chunk_id, chunk_size = struct.unpack("<4sI", hdr)
if chunk_id == b"LIST":
list_type = f.read(4)
list_end = f.tell() + (chunk_size - 4)
if list_type == b"INFO":
while f.tell() < list_end:
sub = f.read(8)
if len(sub) < 8:
break
sub_id, sub_size = struct.unpack("<4sI", sub)
data = f.read(sub_size).rstrip(b"\x00")
if sub_size % 2 == 1:
f.read(1) # padding word-aligned
key = _WAV_INFO_MAP.get(sub_id.decode("ascii", errors="ignore"))
if not key or not data:
continue
for enc in ("utf-8", "latin-1"):
try:
out[key] = data.decode(enc).strip()
break
except UnicodeDecodeError:
continue
else:
f.seek(list_end)
else:
skip = chunk_size + (chunk_size % 2)
f.seek(skip, 1)
except Exception:
pass
return out
def _read_wav(path: str, result: dict) -> dict:
result["format"] = "WAV"
# Prima fallback: leggi il chunk INFO (formato RIFF originale)
info = _read_wav_info_chunk(path)
for k, v in info.items():
if v:
result[k] = v
# Poi leggi ID3 se presente; ha priorita sui valori INFO
w = WAVE(path)
tags = w.tags
if tags is None:
return result
def setif(key, frame_key):
v = _text(tags.get(frame_key))
if v:
result[key] = v
setif("title", "TIT2")
setif("artist", "TPE1")
setif("album_artist", "TPE2")
setif("album", "TALB")
setif("year", "TDRC")
setif("track", "TRCK")
setif("genre", "TCON")
setif("bpm", "TBPM")
setif("key", "TKEY")
for k in tags.keys():
if k.startswith("COMM"):
v = _text(tags[k])
if v:
result["comment"] = v
break
for k in tags.keys():
if k.startswith("APIC"):
apic = tags[k]
result["cover_base64"] = base64.b64encode(apic.data).decode("ascii")
result["cover_mime"] = apic.mime or "image/jpeg"
break
return result
def _read_flac(path: str, result: dict) -> dict:
result["format"] = "FLAC"
f = FLAC(path)
g = lambda k: (f.get(k) or [""])[0] if f.get(k) else ""
result["title"] = g("title")
result["artist"] = g("artist")
result["album_artist"] = g("albumartist")
result["album"] = g("album")
result["year"] = g("date")
result["track"] = g("tracknumber")
result["genre"] = g("genre")
result["bpm"] = g("bpm")
result["key"] = g("initialkey")
result["comment"] = g("comment")
if f.pictures:
pic = f.pictures[0]
result["cover_base64"] = base64.b64encode(pic.data).decode("ascii")
result["cover_mime"] = pic.mime or "image/jpeg"
return result
def read_metadata(path: str) -> dict:
"""Ritorna un dict con tutti i metadati leggibili dal file."""
p = Path(path)
if not p.exists():
raise FileNotFoundError(path)
ext = p.suffix.lower()
if ext not in SUPPORTED_EXTS:
raise ValueError(f"Formato non supportato: {ext}")
result = {
"path": str(p),
"filename": p.name,
"format": "",
"title": "", "artist": "", "album_artist": "",
"album": "", "year": "", "track": "",
"genre": "", "comment": "",
"bpm": "", "key": "",
"duration": 0, "bitrate": 0,
"cover_base64": "", "cover_mime": "",
"where_from": _read_where_from(str(p)),
"is_macos": sys.platform == "darwin",
}
audio = MutagenFile(str(p))
if audio is None:
raise ValueError(f"Impossibile leggere: {ext}")
info = getattr(audio, "info", None)
if info:
result["duration"] = int(getattr(info, "length", 0) or 0)
result["bitrate"] = int((getattr(info, "bitrate", 0) or 0) // 1000)
if ext == ".mp3":
return _read_mp3(str(p), result)
if ext in (".m4a", ".mp4", ".aac"):
return _read_mp4(str(p), result)
if ext == ".flac":
return _read_flac(str(p), result)
if ext == ".wav":
return _read_wav(str(p), result)
return result
def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
def set_or_del(key, frame_cls, value):
if value:
tags[key] = frame_cls(encoding=3, text=value)
elif key in tags:
del tags[key]
set_or_del("TIT2", TIT2, data.get("title", ""))
set_or_del("TPE1", TPE1, data.get("artist", ""))
set_or_del("TPE2", TPE2, data.get("album_artist", ""))
set_or_del("TALB", TALB, data.get("album", ""))
set_or_del("TDRC", TDRC, data.get("year", ""))
set_or_del("TRCK", TRCK, data.get("track", ""))
set_or_del("TCON", TCON, data.get("genre", ""))
set_or_del("TBPM", TBPM, data.get("bpm", ""))
set_or_del("TKEY", TKEY, data.get("key", ""))
# Rimuovi tutti i COMM esistenti
for k in list(tags.keys()):
if k.startswith("COMM"):
del tags[k]
comment = data.get("comment", "")
if comment:
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
# Cover
if remove_cover or cover_path:
for k in list(tags.keys()):
if k.startswith("APIC"):
del tags[k]
if cover_path:
cp = Path(cover_path)
if cp.exists():
ext = cp.suffix.lower()
mime = "image/png" if ext == ".png" else "image/jpeg"
with open(cp, "rb") as f:
cover_data = f.read()
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data))
tags.save(path, v2_version=3)
def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
m = MP4(path)
def set_or_del(key, value):
if value:
m[key] = value
elif key in m:
del m[key]
set_or_del("\xa9nam", data.get("title", ""))
set_or_del("\xa9ART", data.get("artist", ""))
set_or_del("aART", data.get("album_artist", ""))
set_or_del("\xa9alb", data.get("album", ""))
set_or_del("\xa9day", data.get("year", ""))
set_or_del("\xa9gen", data.get("genre", ""))
set_or_del("\xa9cmt", data.get("comment", ""))
track = data.get("track", "")
if track:
try:
parts = track.split("/")
num = int(parts[0])
tot = int(parts[1]) if len(parts) > 1 else 0
m["trkn"] = [(num, tot)]
except (ValueError, IndexError):
pass
elif "trkn" in m:
del m["trkn"]
bpm = data.get("bpm", "")
if bpm:
try:
m["tmpo"] = [int(bpm)]
except ValueError:
pass
elif "tmpo" in m:
del m["tmpo"]
if remove_cover or cover_path:
if "covr" in m:
del m["covr"]
if cover_path:
cp = Path(cover_path)
if cp.exists():
ext = cp.suffix.lower()
fmt = MP4Cover.FORMAT_PNG if ext == ".png" else MP4Cover.FORMAT_JPEG
with open(cp, "rb") as f:
cover_data = f.read()
m["covr"] = [MP4Cover(cover_data, imageformat=fmt)]
m.save()
def _write_flac(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
f = FLAC(path)
def set_or_del(key, value):
if value:
f[key] = value
elif key in f:
del f[key]
set_or_del("title", data.get("title", ""))
set_or_del("artist", data.get("artist", ""))
set_or_del("albumartist", data.get("album_artist", ""))
set_or_del("album", data.get("album", ""))
set_or_del("date", data.get("year", ""))
set_or_del("tracknumber", data.get("track", ""))
set_or_del("genre", data.get("genre", ""))
set_or_del("bpm", data.get("bpm", ""))
set_or_del("initialkey", data.get("key", ""))
set_or_del("comment", data.get("comment", ""))
if remove_cover or cover_path:
f.clear_pictures()
if cover_path:
cp = Path(cover_path)
if cp.exists():
ext = cp.suffix.lower()
pic = Picture()
with open(cp, "rb") as fh:
pic.data = fh.read()
pic.type = 3
pic.mime = "image/png" if ext == ".png" else "image/jpeg"
f.add_picture(pic)
f.save()
def _write_wav(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
w = WAVE(path)
if w.tags is None:
w.add_tags()
tags = w.tags
def set_or_del(key, frame_cls, value):
if value:
tags[key] = frame_cls(encoding=3, text=value)
elif key in tags:
del tags[key]
set_or_del("TIT2", TIT2, data.get("title", ""))
set_or_del("TPE1", TPE1, data.get("artist", ""))
set_or_del("TPE2", TPE2, data.get("album_artist", ""))
set_or_del("TALB", TALB, data.get("album", ""))
set_or_del("TDRC", TDRC, data.get("year", ""))
set_or_del("TRCK", TRCK, data.get("track", ""))
set_or_del("TCON", TCON, data.get("genre", ""))
set_or_del("TBPM", TBPM, data.get("bpm", ""))
set_or_del("TKEY", TKEY, data.get("key", ""))
for k in list(tags.keys()):
if k.startswith("COMM"):
del tags[k]
comment = data.get("comment", "")
if comment:
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
if remove_cover or cover_path:
for k in list(tags.keys()):
if k.startswith("APIC"):
del tags[k]
if cover_path:
cp = Path(cover_path)
if cp.exists():
ext_c = cp.suffix.lower()
mime = "image/png" if ext_c == ".png" else "image/jpeg"
with open(cp, "rb") as f:
cover_data = f.read()
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data))
w.save()
def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
remove_cover: bool = False) -> None:
"""Salva i metadati. Se remove_cover=True rimuove la copertina esistente
senza sostituirla. Se cover_path e fornito, la sostituisce.
Su macOS aggiorna anche l'xattr kMDItemWhereFroms se data['where_from']
e fornito (lista di stringhe)."""
ext = Path(path).suffix.lower()
if ext == ".mp3":
_write_mp3(path, data, cover_path, remove_cover)
elif ext in (".m4a", ".mp4", ".aac"):
_write_mp4(path, data, cover_path, remove_cover)
elif ext == ".flac":
_write_flac(path, data, cover_path, remove_cover)
elif ext == ".wav":
_write_wav(path, data, cover_path, remove_cover)
else:
raise ValueError(f"Formato non supportato: {ext}")
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
if "where_from" in data:
_write_where_from(path, data.get("where_from") or [])
+13
View File
@@ -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.
+376
View File
@@ -0,0 +1,376 @@
"""Registrazione audio da un dispositivo di input via ffmpeg.
macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole)
Windows: usa DirectShow
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ffmpeg, subprocess_flags
_IS_MAC = sys.platform == "darwin"
_IS_WIN = sys.platform == "win32"
# ============================================================
# Stato globale (un solo recording alla volta)
# ============================================================
_current_proc: Optional[subprocess.Popen] = None
_proc_lock = threading.Lock()
_stop_event = threading.Event()
_recording_thread: Optional[threading.Thread] = None
_stderr_buf: list = []
_stderr_lock = threading.Lock()
def is_recording() -> bool:
with _proc_lock:
return _current_proc is not None and _current_proc.poll() is None
# ============================================================
# Lista dispositivi
# ============================================================
def list_input_devices() -> list[dict]:
"""Ritorna la lista dei dispositivi audio di input.
Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}.
is_virtual = True per driver di loopback noti (BlackHole, Soundflower,
Loopback, Audio Hijack, VB-Cable, ...)
"""
ffmpeg = find_ffmpeg()
if not ffmpeg:
return []
if _IS_MAC:
return _list_macos(ffmpeg)
if _IS_WIN:
return _list_windows(ffmpeg)
return []
_VIRTUAL_KEYWORDS = (
"blackhole", "soundflower", "loopback", "audio hijack",
"vb-cable", "vb-audio", "stereo mix", "what u hear",
"obs", "system audio",
)
def _is_virtual(name: str) -> bool:
n = (name or "").lower()
return any(k in n for k in _VIRTUAL_KEYWORDS)
def _list_macos(ffmpeg: str) -> list[dict]:
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
L'output va su stderr."""
try:
proc = subprocess.run(
[ffmpeg, "-hide_banner", "-f", "avfoundation",
"-list_devices", "true", "-i", ""],
capture_output=True, text=True, timeout=10,
**subprocess_flags(),
)
except Exception:
return []
devices: list[dict] = []
in_audio = False
audio_section_re = re.compile(r"AVFoundation\s+audio\s+devices", re.IGNORECASE)
other_section_re = re.compile(r"AVFoundation\s+(?:video)\s+devices", re.IGNORECASE)
line_re = re.compile(r"\[(\d+)\]\s+(.+?)\s*$")
for raw in proc.stderr.splitlines():
line = raw.strip()
if audio_section_re.search(line):
in_audio = True
continue
if other_section_re.search(line):
in_audio = False
continue
if not in_audio:
continue
clean = re.sub(r"^\[AVFoundation[^\]]*\]\s*", "", line)
m = line_re.match(clean)
if not m:
continue
idx, name = m.group(1), m.group(2).strip()
devices.append({"id": idx, "name": name, "is_virtual": _is_virtual(name)})
return devices
def _list_windows(ffmpeg: str) -> list[dict]:
"""Parsa l'output di `ffmpeg -f dshow -list_devices true -i dummy`."""
try:
proc = subprocess.run(
[ffmpeg, "-hide_banner", "-f", "dshow",
"-list_devices", "true", "-i", "dummy"],
capture_output=True, text=True, timeout=10,
**subprocess_flags(),
)
except Exception:
return []
devices: list[dict] = []
in_audio = False
line_re = re.compile(r'"([^"]+)"\s*\((audio|video)\)', re.IGNORECASE)
name_re = re.compile(r'"([^"]+)"')
for raw in proc.stderr.splitlines():
line = raw.strip()
low = line.lower()
if "directshow audio devices" in low:
in_audio = True
continue
if "directshow video devices" in low:
in_audio = False
continue
m = line_re.search(line)
if m:
name, kind = m.group(1), m.group(2).lower()
if kind == "audio":
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
continue
if in_audio:
mn = name_re.search(line)
if mn:
name = mn.group(1)
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
# Dedup preservando l'ordine
seen = set()
out = []
for d in devices:
if d["id"] in seen:
continue
seen.add(d["id"])
out.append(d)
return out
# ============================================================
# Registrazione
# ============================================================
def _build_record_cmd(ffmpeg: str, device_id: str, output_path: str,
bitrate: str) -> list[str]:
"""Comando ffmpeg per registrare su file MP3."""
if _IS_MAC:
return [
ffmpeg, "-hide_banner", "-y",
"-f", "avfoundation",
"-i", f":{device_id}",
"-acodec", "libmp3lame",
"-b:a", bitrate,
"-vn",
output_path,
]
if _IS_WIN:
return [
ffmpeg, "-hide_banner", "-y",
"-f", "dshow",
"-i", f"audio={device_id}",
"-acodec", "libmp3lame",
"-b:a", bitrate,
"-vn",
output_path,
]
raise RuntimeError("Sistema operativo non supportato per la registrazione")
def start_recording(
device_id: str,
output_path: str,
bitrate: str = "320k",
progress_callback: Optional[Callable] = None,
) -> dict:
"""Avvia una registrazione in un thread.
Ritorna {ok, error?, output_path?}.
progress_callback(status, payload) viene chiamato con:
("started", {"output_path": ...})
("tick", {"seconds": int})
("stopped", {"output_path": ..., "seconds": int})
("error", {"error": "..."})
"""
global _current_proc, _recording_thread
if is_recording():
return {"ok": False, "error": "Una registrazione e gia in corso"}
ffmpeg = find_ffmpeg()
if not ffmpeg:
return {"ok": False, "error": "ffmpeg non trovato"}
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
_stop_event.clear()
with _stderr_lock:
_stderr_buf.clear()
def _drain_stderr(proc):
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
del pipe e per salvare le ultime righe ai fini diagnostici."""
try:
for raw in iter(proc.stderr.readline, b""):
if not raw:
break
line = raw.decode("utf-8", errors="ignore").rstrip()
with _stderr_lock:
_stderr_buf.append(line)
# Mantieni solo le ultime 50 righe per non sforare la RAM
if len(_stderr_buf) > 50:
del _stderr_buf[0]
except Exception:
pass
def _worker():
global _current_proc
try:
with _proc_lock:
_current_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=False,
**subprocess_flags(),
)
drain_t = threading.Thread(target=_drain_stderr,
args=(_current_proc,), daemon=True)
drain_t.start()
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
# primi ~1.5s, segnala errore prima di emettere 'started'.
start = time.monotonic()
opened = False
for _ in range(15):
if _current_proc.poll() is not None:
break
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
with _stderr_lock:
if any("Press [q]" in l for l in _stderr_buf):
opened = True
break
time.sleep(0.1)
rc_early = _current_proc.poll()
if not opened and rc_early is not None:
# ffmpeg e' uscito prima di iniziare a registrare
with _proc_lock:
_current_proc = None
if progress_callback:
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
return
if progress_callback:
progress_callback("started", {"output_path": str(out)})
while True:
if _stop_event.is_set():
break
rc = _current_proc.poll()
if rc is not None:
break
elapsed = int(time.monotonic() - start)
if progress_callback:
progress_callback("tick", {"seconds": elapsed})
time.sleep(1.0)
# Stop gracefully
if _current_proc.poll() is None:
try:
_current_proc.stdin.write(b"q\n")
_current_proc.stdin.flush()
except Exception:
pass
try:
_current_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
_current_proc.terminate()
try:
_current_proc.wait(timeout=3)
except subprocess.TimeoutExpired:
_current_proc.kill()
rc = _current_proc.returncode
seconds = int(time.monotonic() - start)
with _proc_lock:
_current_proc = None
if progress_callback:
if rc == 0 or out.exists():
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
else:
progress_callback("error", {"error": _extract_friendly_error(rc)})
except Exception as e:
with _proc_lock:
_current_proc = None
if progress_callback:
progress_callback("error", {"error": str(e)})
_recording_thread = threading.Thread(target=_worker, daemon=True)
_recording_thread.start()
return {"ok": True, "output_path": str(out)}
def _extract_friendly_error(rc: int) -> str:
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
Riconosce i casi tipici: permessi microfono, device occupato,
device non disponibile, ..."""
with _stderr_lock:
lines = list(_stderr_buf)
text = "\n".join(lines).lower()
if "abort" in text and rc == -6 and sys.platform == "darwin":
return (
"ffmpeg abortito (errore -6): probabilmente l'app non ha il "
"permesso 'Microfono' di macOS. Vai su Preferenze di Sistema "
"→ Privacy e sicurezza → Microfono e abilita Terminal/Python. "
"Poi riavvia l'app."
)
if any(s in text for s in ("input/output error", "errno 22", "device not configured")):
return "Dispositivo non disponibile (riavvia il servizio audio o ricontrolla la selezione)."
if "permission" in text or "not authorized" in text or "not permitted" in text:
return "Permesso microfono negato da macOS. Abilita Python/MusicTools in Privacy → Microfono."
if "no such device" in text or "no such audio device" in text:
return "Dispositivo non trovato. Premi '↻ Aggiorna' e riseleziona."
# Restituisci l'ultima riga utile dello stderr (esclude le righe di banner/progress)
for line in reversed(lines):
l = line.strip()
if not l:
continue
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
return l
if "error" in l.lower() or "fail" in l.lower() or "denied" in l.lower():
return l
return f"ffmpeg exit {rc}"
def stop_recording() -> dict:
"""Ferma la registrazione corrente (se attiva)."""
_stop_event.set()
return {"ok": True}
+6 -3
View File
@@ -9,7 +9,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_flags
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"} AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
@@ -61,6 +61,7 @@ def get_bitrate(filepath: str | Path) -> int:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30, timeout=30,
**subprocess_flags(),
) )
data = json.loads(result.stdout) data = json.loads(result.stdout)
bit_rate = int(data.get("format", {}).get("bit_rate", 0)) bit_rate = int(data.get("format", {}).get("bit_rate", 0))
@@ -94,7 +95,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> str:
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError("Ricerca fallita") raise ValueError("Ricerca fallita")
@@ -128,7 +129,7 @@ def update_cover_only(
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
subprocess.run(cmd, capture_output=True, timeout=60) subprocess.run(cmd, capture_output=True, timeout=60, **subprocess_flags())
cover_files = list(temp_dir.glob("cover*.jpg")) cover_files = list(temp_dir.glob("cover*.jpg"))
if not cover_files: if not cover_files:
@@ -152,6 +153,7 @@ def update_cover_only(
], ],
capture_output=True, capture_output=True,
timeout=60, timeout=60,
**subprocess_flags(),
) )
except Exception: except Exception:
_cleanup_temp(temp_dir) _cleanup_temp(temp_dir)
@@ -284,6 +286,7 @@ def upgrade_folder(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
+2 -2
View File
@@ -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,
+1
View File
@@ -1,6 +1,7 @@
pywebview>=5.0 pywebview>=5.0
requests>=2.31.0 requests>=2.31.0
yt-dlp>=2024.0.0 yt-dlp>=2024.0.0
mutagen>=1.47.0
pyobjc-core>=10.0 ; sys_platform == "darwin" pyobjc-core>=10.0 ; sys_platform == "darwin"
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin" pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin" pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
+441 -4
View File
@@ -1,5 +1,5 @@
/* =============================== /* ===============================
MusicDownload — Spotify-like UI MusicTools — Spotify-like UI
=============================== */ =============================== */
:root { :root {
@@ -30,6 +30,18 @@
--pink-hover: #F472B6; --pink-hover: #F472B6;
--pink-dim: #831843; --pink-dim: #831843;
--amber: #F59E0B;
--amber-hover: #FBBF24;
--amber-dim: #78350F;
--rec-red: #EF4444;
--rec-red-hover: #F87171;
--rec-red-dim: #7F1D1D;
--indigo: #6366F1;
--indigo-hover: #818CF8;
--indigo-dim: #312E81;
--red: #E22134; --red: #E22134;
--red-hover: #FF4055; --red-hover: #FF4055;
@@ -233,6 +245,18 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%); background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%);
} }
.hero-amber {
background: linear-gradient(135deg, #F59E0B 0%, #78350F 60%, #3B1E07 100%);
}
.hero-red {
background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%);
}
.hero-indigo {
background: linear-gradient(135deg, #4F46E5 0%, #312E81 60%, #1E1B4B 100%);
}
.hero-content { position: relative; z-index: 2; max-width: 70%; } .hero-content { position: relative; z-index: 2; max-width: 70%; }
.hero-eyebrow { .hero-eyebrow {
@@ -246,6 +270,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.hero-eyebrow.blue { color: var(--blue); } .hero-eyebrow.blue { color: var(--blue); }
.hero-eyebrow.purple { color: var(--purple); } .hero-eyebrow.purple { color: var(--purple); }
.hero-eyebrow.pink { color: var(--pink-hover); } .hero-eyebrow.pink { color: var(--pink-hover); }
.hero-eyebrow.amber { color: var(--amber-hover); }
.hero-eyebrow.red { color: var(--rec-red-hover); }
.hero-eyebrow.indigo { color: var(--indigo-hover); }
.hero-title { .hero-title {
font-size: 36px; font-size: 36px;
@@ -463,6 +490,25 @@ input[type="number"]::-webkit-inner-spin-button {
color: #fff; color: #fff;
} }
.btn-amber {
background: var(--amber);
color: #000;
}
.btn-red {
background: var(--rec-red);
color: #fff;
}
.btn-red.recording {
animation: pulseRed 1.2s ease-in-out infinite;
}
@keyframes pulseRed {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
.btn-success { .btn-success {
background: #00CEC9; background: #00CEC9;
color: #000; color: #000;
@@ -578,30 +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
=============================== */ =============================== */
@@ -819,3 +914,345 @@ input[type="number"]::-webkit-inner-spin-button {
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); } .log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
.main::-webkit-scrollbar-thumb { border-color: var(--bg); } .main::-webkit-scrollbar-thumb { border-color: var(--bg); }
/* ===============================
METADATA EDITOR
=============================== */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 18px;
}
.form-grid .field { margin-bottom: 0; }
.form-grid .field.full { grid-column: 1 / -1; }
.textarea {
border-radius: var(--r-md);
padding: 12px 16px;
resize: vertical;
min-height: 70px;
font-family: inherit;
line-height: 1.5;
}
.meta-info {
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--divider);
color: var(--text-2);
font-size: 12.5px;
}
.meta-info span strong {
color: var(--text);
font-weight: 700;
margin-right: 6px;
}
.cover-card {
display: grid;
grid-template-columns: 180px 1fr;
gap: 24px;
align-items: start;
}
.cover-preview {
width: 180px;
height: 180px;
border-radius: var(--r-md);
background: var(--bg-card-2);
border: 1px dashed var(--bg-input-hover);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-empty {
color: var(--text-3);
font-size: 12px;
text-align: center;
padding: 0 12px;
}
.cover-actions {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.cover-hint {
color: var(--text-3);
font-size: 12px;
margin-top: 4px;
}
.cover-hint.set { color: var(--amber-hover); font-weight: 700; }
.cover-hint.remove { color: var(--red); font-weight: 700; }
@media (max-width: 720px) {
.form-grid { grid-template-columns: 1fr; }
.cover-card { grid-template-columns: 1fr; }
}
/* ===============================
RECORDER
=============================== */
.rec-timer {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 28px;
font-weight: 800;
color: var(--rec-red-hover);
letter-spacing: 1px;
margin-left: auto;
min-width: 130px;
text-align: right;
}
.rec-status {
color: var(--text-2);
font-size: 12.5px;
font-weight: 600;
margin-left: 18px;
}
.rec-result {
color: var(--text);
font-size: 13px;
font-weight: 600;
margin-top: 6px;
word-break: break-all;
user-select: text;
}
.rec-result .badge-dur {
background: rgba(239, 68, 68, 0.15);
color: var(--rec-red-hover);
padding: 2px 10px;
border-radius: 10px;
font-size: 11.5px;
font-weight: 700;
margin-right: 10px;
}
/* ===============================
GUIDE
=============================== */
.guide-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 32px;
align-items: start;
}
.guide-toc {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 10px;
background: var(--bg-card);
border-radius: var(--r-md);
max-height: calc(100vh - 80px);
overflow-y: auto;
}
.toc-link {
color: var(--text-2);
text-decoration: none;
padding: 8px 12px;
border-radius: var(--r-sm);
font-size: 13px;
font-weight: 600;
transition: background-color 0.15s, color 0.15s;
}
.toc-link:hover {
background: var(--bg-input);
color: var(--text);
}
.toc-link.active {
background: var(--indigo-dim);
color: var(--indigo-hover);
}
.guide-content {
min-width: 0; /* evita overflow horizontal */
color: var(--text-2);
font-size: 14px;
line-height: 1.65;
}
.g-section {
background: var(--bg-card);
border-radius: var(--r-md);
padding: 24px 28px;
margin-bottom: 18px;
scroll-margin-top: 12px;
}
.g-section h2 {
font-size: 22px;
font-weight: 800;
color: var(--text);
margin-bottom: 10px;
letter-spacing: -0.3px;
}
.g-section h3 {
font-size: 15px;
font-weight: 800;
color: var(--text);
margin-top: 22px;
margin-bottom: 8px;
letter-spacing: 0.2px;
}
.g-section h4 {
font-size: 13px;
font-weight: 700;
color: var(--indigo-hover);
margin-top: 16px;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.6px;
}
.g-section p {
margin-bottom: 10px;
}
.g-section ul,
.g-section ol {
padding-left: 22px;
margin-bottom: 12px;
}
.g-section li {
margin-bottom: 6px;
}
.g-section strong { color: var(--text); }
.g-section code {
background: var(--bg-input);
padding: 2px 7px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px;
color: var(--green-hover);
}
.g-section pre {
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 12px 16px;
margin: 10px 0 14px;
overflow-x: auto;
}
.g-section pre code {
background: transparent;
padding: 0;
color: var(--text);
font-size: 13px;
}
.g-section a {
color: var(--indigo-hover);
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid transparent;
}
.g-section a:hover {
border-bottom-color: var(--indigo-hover);
}
.kbd {
background: var(--bg-input);
border: 1px solid var(--bg-input-hover);
color: var(--text);
padding: 1px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.g-box {
padding: 14px 18px;
border-radius: var(--r-sm);
margin: 14px 0;
font-size: 13.5px;
line-height: 1.55;
}
.g-box.info {
background: rgba(99, 102, 241, 0.10);
border-left: 3px solid var(--indigo);
}
.g-box.success {
background: rgba(29, 185, 84, 0.10);
border-left: 3px solid var(--green);
}
.g-box.warn {
background: rgba(253, 203, 110, 0.10);
border-left: 3px solid #FDCB6E;
}
.g-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
.g-table th,
.g-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--divider);
vertical-align: top;
}
.g-table th {
color: var(--text);
font-weight: 700;
font-size: 11.5px;
letter-spacing: 0.5px;
text-transform: uppercase;
background: var(--bg-card-2);
}
.g-table tr:last-child td {
border-bottom: none;
}
@media (max-width: 900px) {
.guide-layout { grid-template-columns: 1fr; }
.guide-toc {
position: static;
flex-direction: row;
flex-wrap: wrap;
max-height: none;
}
}
+477 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MusicDownload</title> <title>MusicTools</title>
<link rel="stylesheet" href="css/style.css" /> <link rel="stylesheet" href="css/style.css" />
</head> </head>
<body> <body>
@@ -15,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>
@@ -33,10 +33,22 @@
<span class="nav-icon">⚡</span> <span class="nav-icon">⚡</span>
<span>Upgrade</span> <span>Upgrade</span>
</button> </button>
<button class="nav-item" data-view="meta">
<span class="nav-icon">🏷</span>
<span>Metadati</span>
</button>
<button class="nav-item" data-view="record">
<span class="nav-icon">●</span>
<span>Registra</span>
</button>
<button class="nav-item" data-view="settings"> <button class="nav-item" data-view="settings">
<span class="nav-icon">⚙</span> <span class="nav-icon">⚙</span>
<span>Impostazioni</span> <span>Impostazioni</span>
</button> </button>
<button class="nav-item" data-view="guide">
<span class="nav-icon">📖</span>
<span>Guida</span>
</button>
</nav> </nav>
<div class="footer"> <div class="footer">
@@ -63,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">
@@ -136,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>
@@ -147,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">
@@ -237,6 +267,178 @@
</div> </div>
</section> </section>
<!-- ===== VIEW: REGISTRA ===== -->
<section class="view" id="view-record">
<header class="hero hero-red">
<div class="hero-content">
<div class="hero-eyebrow red">REGISTRAZIONE</div>
<h1 class="hero-title">Cattura audio</h1>
<p class="hero-subtitle">Registra in MP3 320 kbps da qualsiasi ingresso audio (mixer, scheda, microfono, BlackHole/loopback)</p>
</div>
<div class="hero-deco">●</div>
</header>
<h2 class="section-label">Dispositivo</h2>
<div class="card">
<div class="field">
<label class="field-label">Ingresso audio</label>
<div class="row">
<select id="recDevice" class="input pill">
<option value="">Caricamento…</option>
</select>
<button class="btn btn-ghost pill btn-sm" id="recRefreshBtn">↻ Aggiorna</button>
</div>
</div>
<div class="hint" id="recHint">
<span class="hint-ico">ⓘ</span>
<div>
<strong>Per registrare l'audio di sistema (Spotify/YouTube):</strong>
installa <code>brew install blackhole-2ch</code>, imposta l'uscita audio di macOS su <strong>BlackHole 2ch</strong>, poi seleziona qui sotto <strong>🔄 BlackHole 2ch (loopback)</strong>.
<br><br>
Per sentire l'audio mentre registri serve un <strong>Multi-Output Device</strong> in <em>Configurazione MIDI Audio</em> (BlackHole + altoparlanti) — guida completa nel
<a href="#" id="recOpenGuide">README su GitHub</a>.
</div>
</div>
</div>
<h2 class="section-label">Destinazione</h2>
<div class="card">
<div class="field">
<label class="field-label">Cartella output</label>
<div class="row">
<div class="path-display" id="recPathDisplay">Nessuna cartella selezionata</div>
<button class="btn btn-ghost pill btn-sm" id="recBrowseBtn">Sfoglia</button>
</div>
</div>
<div class="field">
<label class="field-label">Nome file (opzionale)</label>
<input type="text" id="recFilename" class="input pill" placeholder="Registrazione_AAAAMMGG_HHMMSS.mp3" />
</div>
<div class="field inline">
<label class="field-label">Bitrate</label>
<select id="recBitrate" class="input pill input-sm">
<option value="128k">128 kbps</option>
<option value="192k">192 kbps</option>
<option value="256k">256 kbps</option>
<option value="320k" selected>320 kbps</option>
</select>
</div>
</div>
<div class="card actions">
<button class="btn btn-red pill btn-lg" id="recStartBtn">
<span class="ico">●</span> REC
</button>
<button class="btn btn-danger pill btn-lg" id="recStopBtn" disabled>
<span class="ico">◼</span> Stop
</button>
<div class="rec-timer" id="recTimer">00:00:00</div>
<div class="rec-status" id="recStatus"></div>
</div>
<div class="card" id="recOutputCard" hidden>
<div class="micro-label">Ultima registrazione</div>
<div class="rec-result" id="recResult"></div>
</div>
</section>
<!-- ===== VIEW: METADATI ===== -->
<section class="view" id="view-meta">
<header class="hero hero-amber">
<div class="hero-content">
<div class="hero-eyebrow amber">METADATI</div>
<h1 class="hero-title">Modifica tag audio</h1>
<p class="hero-subtitle">Titolo · Artista · Album · Anno · Copertina · BPM · Key · MP3/M4A/FLAC</p>
</div>
<div class="hero-deco">🏷</div>
</header>
<h2 class="section-label">File</h2>
<div class="card">
<div class="row">
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
</div>
<div class="meta-info" id="metaInfo" hidden></div>
</div>
<div id="metaForm" hidden>
<h2 class="section-label">Tag</h2>
<div class="card">
<div class="form-grid">
<div class="field full">
<label class="field-label">Titolo</label>
<input type="text" id="metaTitle" class="input pill">
</div>
<div class="field full">
<label class="field-label">Artista</label>
<input type="text" id="metaArtist" class="input pill">
</div>
<div class="field">
<label class="field-label">Album</label>
<input type="text" id="metaAlbum" class="input pill">
</div>
<div class="field">
<label class="field-label">Album Artist</label>
<input type="text" id="metaAlbumArtist" class="input pill">
</div>
<div class="field">
<label class="field-label">Anno</label>
<input type="text" id="metaYear" class="input pill">
</div>
<div class="field">
<label class="field-label">Traccia</label>
<input type="text" id="metaTrack" class="input pill" placeholder="1/12">
</div>
<div class="field">
<label class="field-label">Genere</label>
<input type="text" id="metaGenre" class="input pill">
</div>
<div class="field">
<label class="field-label">BPM</label>
<input type="text" id="metaBpm" class="input pill">
</div>
<div class="field">
<label class="field-label">Key</label>
<input type="text" id="metaKey" class="input pill">
</div>
<div class="field full">
<label class="field-label">Commento</label>
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
</div>
<div class="field full" id="metaWhereFromField" hidden>
<label class="field-label">
Origine (macOS · kMDItemWhereFroms)
</label>
<textarea id="metaWhereFrom" class="input textarea" rows="2" placeholder="https://... (uno per riga)"></textarea>
<div class="cover-hint">Extended attribute macOS — una URL per riga. Vuoto = rimuove l'attributo.</div>
</div>
</div>
</div>
<h2 class="section-label">Copertina</h2>
<div class="card cover-card">
<div class="cover-preview" id="metaCoverPreview">
<span class="cover-empty">Nessuna copertina</span>
</div>
<div class="cover-actions">
<button class="btn btn-ghost pill" id="metaCoverBtn">📂 Cambia copertina</button>
<button class="btn btn-ghost pill" id="metaCoverRemoveBtn">✕ Rimuovi</button>
<div class="cover-hint" id="metaCoverHint"></div>
</div>
</div>
<div class="card actions">
<button class="btn btn-amber pill btn-lg" id="metaSaveBtn">💾 Salva tag</button>
<button class="btn btn-ghost pill btn-lg" id="metaReloadBtn">↺ Ricarica</button>
<div class="counter" id="metaStatus"></div>
</div>
</div>
</section>
<!-- ===== VIEW: SETTINGS ===== --> <!-- ===== VIEW: SETTINGS ===== -->
<section class="view" id="view-settings"> <section class="view" id="view-settings">
<header class="hero hero-purple"> <header class="hero hero-purple">
@@ -337,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>
+366 -2
View File
@@ -1,5 +1,5 @@
/* ====================================================== /* ======================================================
MusicDownload — Frontend (vanilla JS) MusicTools — Frontend (vanilla JS)
====================================================== */ ====================================================== */
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
@@ -15,6 +15,8 @@ const state = {
downloading: false, downloading: false,
upgrading: false, upgrading: false,
videoDownloading: false, videoDownloading: false,
meta: { path: "", newCoverPath: "", removeCover: false },
rec: { devices: [], outputDir: "", recording: false, seconds: 0 },
}; };
// Wait for pywebview ready // Wait for pywebview ready
@@ -25,6 +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
// ============================================================ // ============================================================
@@ -103,8 +123,56 @@ const bridgeHandlers = {
$("#videoDownloadBtn").disabled = false; $("#videoDownloadBtn").disabled = false;
$("#stopVideoBtn").disabled = true; $("#stopVideoBtn").disabled = true;
}, },
"recording:event": (p) => {
const status = p.status;
if (status === "started") {
state.rec.recording = true;
state.rec.seconds = 0;
$("#recStartBtn").disabled = true;
$("#recStartBtn").classList.add("recording");
$("#recStopBtn").disabled = false;
$("#recDevice").disabled = true;
$("#recRefreshBtn").disabled = true;
$("#recTimer").textContent = "00:00:00";
$("#recStatus").textContent = "● Registrazione in corso…";
} else if (status === "tick") {
state.rec.seconds = p.seconds || 0;
$("#recTimer").textContent = formatHms(state.rec.seconds);
} else if (status === "stopped") {
state.rec.recording = false;
$("#recStartBtn").disabled = false;
$("#recStartBtn").classList.remove("recording");
$("#recStopBtn").disabled = true;
$("#recDevice").disabled = false;
$("#recRefreshBtn").disabled = false;
$("#recStatus").textContent = "";
$("#recOutputCard").hidden = false;
$("#recResult").innerHTML = `
<span class="badge-dur">${formatHms(p.seconds || 0)}</span>
${p.output_path || ""}
`;
toast("Registrazione salvata", "success");
} else if (status === "error") {
state.rec.recording = false;
$("#recStartBtn").disabled = false;
$("#recStartBtn").classList.remove("recording");
$("#recStopBtn").disabled = true;
$("#recDevice").disabled = false;
$("#recRefreshBtn").disabled = false;
$("#recStatus").textContent = "";
toast("Errore registrazione: " + (p.error || ""), "error");
}
},
}; };
function formatHms(sec) {
const h = Math.floor(sec / 3600).toString().padStart(2, "0");
const m = Math.floor((sec % 3600) / 60).toString().padStart(2, "0");
const s = Math.floor(sec % 60).toString().padStart(2, "0");
return `${h}:${m}:${s}`;
}
// ============================================================ // ============================================================
// Logging // Logging
// ============================================================ // ============================================================
@@ -195,6 +263,34 @@ async function init() {
// Upgrade tab — default threshold // Upgrade tab — default threshold
$("#upThreshold").value = state.config.hq_threshold || 310; $("#upThreshold").value = state.config.hq_threshold || 310;
// Record tab — default output dir + carica dispositivi
state.rec.outputDir = state.config.output_dir || "";
if (state.rec.outputDir) {
$("#recPathDisplay").textContent = state.rec.outputDir;
$("#recPathDisplay").classList.remove("empty");
}
await refreshRecDevices();
}
async function refreshRecDevices() {
const res = await window.pywebview.api.list_audio_inputs();
const select = $("#recDevice");
select.innerHTML = "";
state.rec.devices = res.devices || [];
if (!state.rec.devices.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "Nessun dispositivo trovato";
select.appendChild(opt);
return;
}
for (const d of state.rec.devices) {
const opt = document.createElement("option");
opt.value = d.id;
opt.textContent = d.is_virtual ? `🔄 ${d.name} (loopback)` : d.name;
select.appendChild(opt);
}
} }
// ============================================================ // ============================================================
@@ -262,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,
@@ -297,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) {
@@ -318,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");
@@ -391,6 +513,248 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
$("#stopUpgradeBtn").disabled = true; $("#stopUpgradeBtn").disabled = true;
}); });
// ============================================================
// RECORDER tab
// ============================================================
$("#recRefreshBtn").addEventListener("click", refreshRecDevices);
$("#recOpenGuide").addEventListener("click", (e) => {
e.preventDefault();
showView("guide");
setTimeout(() => {
const el = document.getElementById("g-record");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, 80);
});
// ============================================================
// GUIDE — link esterni + TOC active highlight
// ============================================================
document.querySelectorAll("#view-guide a[data-ext]").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
window.pywebview.api.open_external_url(a.dataset.ext);
});
});
document.querySelectorAll(".toc-link").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
const id = a.getAttribute("href").slice(1);
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
// Aggiorna TOC active in base allo scroll della view
function setupGuideObserver() {
const sections = document.querySelectorAll(".g-section[id]");
const tocLinks = document.querySelectorAll(".toc-link");
if (!sections.length || !tocLinks.length) return;
const main = document.querySelector(".main");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const id = entry.target.id;
tocLinks.forEach((l) => {
l.classList.toggle("active", l.getAttribute("href") === "#" + id);
});
});
},
{ root: main, rootMargin: "-20% 0px -65% 0px", threshold: 0 }
);
sections.forEach((s) => observer.observe(s));
}
setupGuideObserver();
$("#recBrowseBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.browse_directory();
if (path) {
state.rec.outputDir = path;
$("#recPathDisplay").textContent = path;
$("#recPathDisplay").classList.remove("empty");
}
});
$("#recStartBtn").addEventListener("click", async () => {
const deviceId = $("#recDevice").value;
if (!deviceId) {
toast("Seleziona un dispositivo", "error");
return;
}
if (!state.rec.outputDir) {
toast("Seleziona una cartella di destinazione", "error");
return;
}
const res = await window.pywebview.api.start_audio_recording({
device_id: deviceId,
output_dir: state.rec.outputDir,
filename: $("#recFilename").value.trim(),
bitrate: $("#recBitrate").value,
});
if (!res.ok) {
toast(res.error || "Errore", "error");
}
});
$("#recStopBtn").addEventListener("click", async () => {
$("#recStopBtn").disabled = true;
await window.pywebview.api.stop_audio_recording();
});
// ============================================================
// METADATA editor
// ============================================================
function formatDuration(sec) {
if (!sec) return "—";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function populateMetaForm(d) {
$("#metaTitle").value = d.title || "";
$("#metaArtist").value = d.artist || "";
$("#metaAlbum").value = d.album || "";
$("#metaAlbumArtist").value = d.album_artist || "";
$("#metaYear").value = d.year || "";
$("#metaTrack").value = d.track || "";
$("#metaGenre").value = d.genre || "";
$("#metaBpm").value = d.bpm || "";
$("#metaKey").value = d.key || "";
$("#metaComment").value = d.comment || "";
// WhereFroms (macOS only)
if (d.is_macos) {
$("#metaWhereFromField").hidden = false;
$("#metaWhereFrom").value = (d.where_from || []).join("\n");
} else {
$("#metaWhereFromField").hidden = true;
$("#metaWhereFrom").value = "";
}
// Info riga sotto il path
const info = $("#metaInfo");
info.hidden = false;
info.innerHTML = `
<span><strong>Formato:</strong>${d.format || "—"}</span>
<span><strong>Bitrate:</strong>${d.bitrate ? d.bitrate + " kbps" : "—"}</span>
<span><strong>Durata:</strong>${formatDuration(d.duration)}</span>
`;
// Cover
const preview = $("#metaCoverPreview");
preview.innerHTML = "";
if (d.cover_base64) {
const img = document.createElement("img");
img.src = `data:${d.cover_mime || "image/jpeg"};base64,${d.cover_base64}`;
preview.appendChild(img);
} else {
const span = document.createElement("span");
span.className = "cover-empty";
span.textContent = "Nessuna copertina";
preview.appendChild(span);
}
$("#metaCoverHint").textContent = "";
$("#metaCoverHint").className = "cover-hint";
state.meta.newCoverPath = "";
state.meta.removeCover = false;
}
async function loadMetaFile(path) {
const res = await window.pywebview.api.read_metadata(path);
if (!res.ok) {
toast("Errore lettura: " + (res.error || ""), "error");
return;
}
state.meta.path = path;
$("#metaPathDisplay").textContent = path;
$("#metaPathDisplay").classList.remove("empty");
$("#metaForm").hidden = false;
populateMetaForm(res.data);
}
$("#metaPickBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.pick_audio_file();
if (path) await loadMetaFile(path);
});
$("#metaCoverBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.pick_image_file();
if (!path) return;
state.meta.newCoverPath = path;
state.meta.removeCover = false;
// Mostra anteprima leggendo il file via fetch (file:// non funziona da webview;
// usiamo solo il nome come hint, l'utente vedra il risultato dopo Save)
const hint = $("#metaCoverHint");
hint.textContent = "Nuova copertina: " + path.split("/").pop();
hint.className = "cover-hint set";
});
$("#metaCoverRemoveBtn").addEventListener("click", () => {
state.meta.newCoverPath = "";
state.meta.removeCover = true;
const preview = $("#metaCoverPreview");
preview.innerHTML = '<span class="cover-empty">Verra rimossa al salvataggio</span>';
const hint = $("#metaCoverHint");
hint.textContent = "Copertina marcata per rimozione";
hint.className = "cover-hint remove";
});
$("#metaReloadBtn").addEventListener("click", async () => {
if (state.meta.path) await loadMetaFile(state.meta.path);
});
$("#metaSaveBtn").addEventListener("click", async () => {
if (!state.meta.path) {
toast("Nessun file caricato", "error");
return;
}
$("#metaSaveBtn").disabled = true;
const status = $("#metaStatus");
status.textContent = "Salvataggio…";
const payload = {
path: state.meta.path,
data: {
title: $("#metaTitle").value,
artist: $("#metaArtist").value,
album: $("#metaAlbum").value,
album_artist: $("#metaAlbumArtist").value,
year: $("#metaYear").value,
track: $("#metaTrack").value,
genre: $("#metaGenre").value,
bpm: $("#metaBpm").value,
key: $("#metaKey").value,
comment: $("#metaComment").value,
where_from: $("#metaWhereFrom").value
.split("\n")
.map(s => s.trim())
.filter(Boolean),
},
cover_path: state.meta.newCoverPath,
remove_cover: state.meta.removeCover,
};
const res = await window.pywebview.api.save_metadata(payload);
$("#metaSaveBtn").disabled = false;
if (res.ok) {
status.textContent = "✓ Tag salvati";
toast("Tag salvati", "success");
// Ricarica per mostrare lo stato aggiornato (es. nuova cover)
await loadMetaFile(state.meta.path);
} else {
status.textContent = "";
toast("Errore: " + (res.error || ""), "error");
}
});
// ============================================================ // ============================================================
// SETTINGS tab // SETTINGS tab
// ============================================================ // ============================================================