Compare commits

...
12 Commits
Author SHA1 Message Date
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
14 changed files with 1909 additions and 105 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
+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
+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
+10 -9
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
@@ -210,7 +210,7 @@ def run_pyinstaller():
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--name", "MusicDownload", "--name", "MusicTools",
"--windowed", "--windowed",
"--onedir", "--onedir",
"--noconfirm", "--noconfirm",
@@ -220,6 +220,7 @@ def run_pyinstaller():
"--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
@@ -229,7 +230,7 @@ def run_pyinstaller():
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 +238,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 +249,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 +272,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()
+10 -9
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
@@ -101,7 +101,7 @@ def run_pyinstaller():
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--name", "MusicDownload", "--name", "MusicTools",
"--windowed", "--windowed",
"--onedir", "--onedir",
"--noconfirm", "--noconfirm",
@@ -116,6 +116,7 @@ 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
@@ -125,11 +126,11 @@ def run_pyinstaller():
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 +138,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 +167,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.4.0"
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
+32 -13
View File
@@ -418,10 +418,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
@@ -461,7 +465,7 @@ def download_video(
return return
total = len(entries) total = len(entries)
fmt = _video_format(quality) fmt = _video_format(quality) if not audio_only else "bestaudio/best"
for i, entry in enumerate(entries): for i, entry in enumerate(entries):
if is_stopped(): if is_stopped():
@@ -483,16 +487,31 @@ def download_video(
if progress_callback: if progress_callback:
progress_callback(i, total, title, "downloading", 0) progress_callback(i, total, title, "downloading", 0)
cmd = [ if audio_only:
ytdlp, cmd = [
"-f", fmt, ytdlp,
"--merge-output-format", "mp4", "-f", fmt,
"--add-metadata", "--extract-audio",
"--no-check-certificates", "--audio-format", "mp3",
"--newline", "--audio-quality", audio_bitrate.rstrip("Kk"),
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"), "--embed-thumbnail",
entry_url, "--add-metadata",
] "--no-check-certificates",
"--newline",
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
entry_url,
]
else:
cmd = [
ytdlp,
"-f", fmt,
"--merge-output-format", "mp4",
"--add-metadata",
"--no-check-certificates",
"--newline",
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
entry_url,
]
ffmpeg_dir = find_ffmpeg_dir() ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir: if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir]) cmd.extend(["--ffmpeg-location", ffmpeg_dir])
+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 [])
+373
View File
@@ -0,0 +1,373 @@
"""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
_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,
)
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,
)
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,
)
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}
+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"
+223 -4
View File
@@ -1,5 +1,5 @@
/* =============================== /* ===============================
MusicDownload — Spotify-like UI MusicTools — Spotify-like UI
=============================== */ =============================== */
:root { :root {
@@ -30,6 +30,14 @@
--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;
--red: #E22134; --red: #E22134;
--red-hover: #FF4055; --red-hover: #FF4055;
@@ -233,6 +241,14 @@ 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-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 +262,8 @@ 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-title { .hero-title {
font-size: 36px; font-size: 36px;
@@ -463,6 +481,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 +615,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 +905,136 @@ 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;
}
+194 -3
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,6 +33,14 @@
<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>
@@ -136,6 +144,14 @@
</div> </div>
<div class="field inline"> <div class="field inline">
<label class="field-label">Modalità</label>
<div class="segmented" id="videoMode">
<button type="button" class="seg-btn active" data-mode="video">🎬 Video (MP4)</button>
<button type="button" class="seg-btn" data-mode="audio">🎵 Solo audio (MP3)</button>
</div>
</div>
<div class="field inline" id="videoQualityField">
<label class="field-label">Qualità</label> <label class="field-label">Qualità</label>
<select id="videoQuality" class="input pill input-sm"> <select id="videoQuality" class="input pill input-sm">
<option value="best">Migliore disponibile</option> <option value="best">Migliore disponibile</option>
@@ -147,7 +163,10 @@
<div class="hint"> <div class="hint">
<span class="hint-ico">ⓘ</span> <span class="hint-ico">ⓘ</span>
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi). <div>
In modalità <strong>Solo audio</strong> il file viene salvato in MP3 al bitrate impostato in Impostazioni → Download.<br>
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi).
</div>
</div> </div>
</div> </div>
@@ -237,6 +256,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">
+289 -1
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
@@ -103,8 +105,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 +245,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);
}
} }
// ============================================================ // ============================================================
@@ -297,6 +375,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 +407,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 +481,204 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
$("#stopUpgradeBtn").disabled = true; $("#stopUpgradeBtn").disabled = true;
}); });
// ============================================================
// RECORDER tab
// ============================================================
$("#recRefreshBtn").addEventListener("click", refreshRecDevices);
$("#recOpenGuide").addEventListener("click", (e) => {
e.preventDefault();
window.pywebview.api.open_external_url(
"https://github.com/luzadev/musicdownload#guida-rapida-registrazione-audio-di-sistema-macos"
);
});
$("#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
// ============================================================ // ============================================================