Compare commits

...
5 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
12 changed files with 318 additions and 99 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
+50 -23
View File
@@ -1,4 +1,4 @@
# MusicDownload # MusicTools
Applicazione desktop per **scaricare musica e video**, **modificare i metadati** dei file audio e **registrare l'audio** da qualsiasi ingresso del computer. Applicazione desktop per **scaricare musica e video**, **modificare i metadati** dei file audio e **registrare l'audio** da qualsiasi ingresso del computer.
@@ -8,6 +8,8 @@ Sviluppata da **LuZa**.
Repo: <https://github.com/luzadev/musicdownload> Repo: <https://github.com/luzadev/musicdownload>
> ℹ️ Il repo Git conserva il nome storico **`musicdownload`** (l'app prima si chiamava così); URL stabili per release già pubblicate e installazioni esistenti.
## Funzionalità ## Funzionalità
| Tab | Cosa fa | | Tab | Cosa fa |
@@ -75,34 +77,59 @@ Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTu
macOS non espone nativamente l'output audio del sistema. Per registrare quello che esce dagli speaker (es. uno stream Spotify/YouTube), serve un driver virtuale di **loopback**. Il più diffuso è **BlackHole**, gratuito e open source. macOS non espone nativamente l'output audio del sistema. Per registrare quello che esce dagli speaker (es. uno stream Spotify/YouTube), serve un driver virtuale di **loopback**. Il più diffuso è **BlackHole**, gratuito e open source.
### Installazione ### 1. Installazione
```bash ```bash
brew install blackhole-2ch brew install blackhole-2ch
``` ```
### Configurazione > Subito dopo l'installazione, se non vedi BlackHole nella tab Registra, riavvia il servizio audio:
> ```bash
> sudo killall coreaudiod
> ```
> Si rilancia da solo in pochi secondi, nessun danno. In alternativa, logout/login o riavvio del Mac.
1. **Preferenze di Sistema** → **Audio** → **Uscita** → seleziona **BlackHole 2ch** ### 2. Permesso Microfono
2. Apri la tab **● Registra** in MusicDownload e clicca **↻ Aggiorna**
3. Nel dropdown vedrai **🔄 BlackHole 2ch (loopback)** — selezionalo
4. Premi **● REC**, fai partire lo stream, premi **◼ Stop**
### Vuoi sentire ancora l'audio mentre registri? Anche se non è un microfono fisico, macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation.
Crea un **Multi-Output Device** in **Audio MIDI Setup**: Vai su **Preferenze di Sistema → Privacy e sicurezza → Microfono** e abilita l'app da cui lanci MusicTools (Terminal/iTerm in sviluppo, oppure MusicTools.app per la build distribuita). Se non vedi nessuna voce, comparirà un popup la prima volta che premi REC — accetta.
1. Apri **Configurazione MIDI Audio** (`Utility → Audio MIDI Setup`) ### 3. Setup A — minimo (registri ma non senti l'audio durante)
2. `+` in basso a sinistra → **Crea dispositivo aggregato a uscita multipla**
3. Seleziona **BlackHole 2ch** + i tuoi altoparlanti/cuffie
4. Imposta questo Multi-Output come uscita di sistema
Così l'audio va contemporaneamente agli speaker (lo senti) e a BlackHole (viene registrato). 1. Click sull'icona altoparlante nella barra menu → **Output → BlackHole 2ch**
*Oppure: Preferenze di Sistema → Audio → Uscita → BlackHole 2ch*
2. Fai partire lo stream (Spotify/YouTube): non sentirai nulla — è normale, l'audio va a BlackHole
3. App MusicTools → tab **● Registra** → dispositivo **🔄 BlackHole 2ch (loopback)** → **REC**
4. **Stop** e riascolta il file MP3 generato
### 4. Setup B — completo (registri E senti l'audio contemporaneamente, consigliato)
1. Apri **Configurazione MIDI Audio** (`Applicazioni → Utility → Configurazione MIDI Audio`)
2. In basso a sinistra: **+ → Crea dispositivo aggregato a uscita multipla** (Multi-Output Device)
3. Nella colonna a destra, spunta sia **BlackHole 2ch** sia i tuoi **Altoparlanti integrati** (o le cuffie)
4. Imposta **BlackHole 2ch** come **Master Device** (menù a tendina in alto)
5. Abilita **Drift Correction** sulla riga degli altoparlanti
6. (Opzionale) Rinomina il dispositivo aggregato in `Speakers + BlackHole`
7. **Preferenze di Sistema → Audio → Uscita** → seleziona `Speakers + BlackHole`
8. Nella tab Registra dell'app scegli **🔄 BlackHole 2ch (loopback)** — **NON il dispositivo aggregato**, quello è solo per riprodurre
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
### Troubleshooting
| Sintomo | Causa probabile | Fix |
|---|---|---|
| BlackHole non compare nel dropdown | CoreAudio non l'ha ancora caricato dopo l'install | `sudo killall coreaudiod` poi premi **↻ Aggiorna** |
| Errore `ffmpeg exit -6` o `permesso Microfono mancante` | macOS nega l'accesso al device | Privacy e sicurezza → Microfono → abilita Terminal/MusicTools |
| File MP3 muto | Output di sistema non passa per BlackHole | Cambia output su BlackHole o Multi-Output Device (vedi Setup A/B) |
| Audio doppio/eco nel file | Stai registrando da BlackHole *e* da microfono fisico simultaneamente | Verifica che nel dropdown ci sia solo `BlackHole 2ch` |
| Audio crackly/distorto | Sample rate non allineato | In Audio MIDI Setup, BlackHole + altoparlanti tutti a 48 kHz |
## Struttura progetto ## Struttura progetto
``` ```
MusicDownload/ MusicTools/
├── main.py # Entry point — bootstrap pywebview ├── main.py # Entry point — bootstrap pywebview
├── requirements.txt ├── requirements.txt
├── build_macos.py # Script build macOS (.app) ├── build_macos.py # Script build macOS (.app)
@@ -144,8 +171,8 @@ Il file `config.json` viene creato automaticamente al primo avvio:
Posizione: Posizione:
- **Sviluppo**: directory del progetto - **Sviluppo**: directory del progetto
- **macOS bundle**: `~/Library/Application Support/MusicDownload/` - **macOS bundle**: `~/Library/Application Support/MusicTools/`
- **Windows bundle**: `%APPDATA%/MusicDownload/` - **Windows bundle**: `%APPDATA%/MusicTools/`
## Build locale ## Build locale
@@ -157,7 +184,7 @@ pip install pyinstaller
python3 build_macos.py python3 build_macos.py
``` ```
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`). Produce `dist/MusicTools.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
### Windows ### Windows
@@ -166,14 +193,14 @@ pip install pyinstaller
python build_windows.py python build_windows.py
``` ```
Produce `dist/MusicDownload/MusicDownload.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build. Produce `dist/MusicTools/MusicTools.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build.
## Build & Release automatiche (GitHub Actions) ## Build & Release automatiche (GitHub Actions)
Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`: Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`:
1. **`build-macos`** (runner `macos-latest`) → `MusicDownload-macOS.zip` (la `.app` zippata con `ditto`) 1. **`build-macos`** (runner `macos-latest`) → `MusicTools-macOS.zip` (la `.app` zippata con `ditto`)
2. **`build-windows`** (runner `windows-latest`) → `MusicDownload-Windows.zip` 2. **`build-windows`** (runner `windows-latest`) → `MusicTools-Windows.zip`
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati 3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
Per pubblicare una nuova versione: Per pubblicare una nuova versione:
@@ -186,10 +213,10 @@ git push origin v1.3.0
Per fare solo una build di test (senza release): Per fare solo una build di test (senza release):
```bash ```bash
gh workflow run "Build MusicDownload" --ref main gh workflow run "Build MusicTools" --ref main
``` ```
oppure dalla UI: **Actions → Build MusicDownload → Run workflow**. oppure dalla UI: **Actions → Build MusicTools → Run workflow**.
Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>. Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>.
+11 -6
View File
@@ -38,7 +38,7 @@ SPOTIFY_GUIDE_TEXT = """\
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free). 1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
2) Clicca "Create app". 2) Clicca "Create app".
3) Compila: 3) Compila:
- App name: MusicDownload (o quello che vuoi) - App name: MusicTools (o quello che vuoi)
- Description: a piacere - Description: a piacere
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato) - Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
- Seleziona "Web API" - Seleziona "Web API"
@@ -148,7 +148,7 @@ class Api:
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest" API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
try: try:
resp = requests.get(API_URL, headers={ resp = requests.get(API_URL, headers={
"User-Agent": "MusicDownload", "User-Agent": "MusicTools",
"Accept": "application/vnd.github+json", "Accept": "application/vnd.github+json",
}, timeout=10) }, timeout=10)
resp.raise_for_status() resp.raise_for_status()
@@ -640,6 +640,7 @@ class Api:
urls = payload.get("urls") or [] urls = payload.get("urls") or []
output_dir = (payload.get("output_dir") or "").strip() output_dir = (payload.get("output_dir") or "").strip()
quality = payload.get("quality") or "1080p" quality = payload.get("quality") or "1080p"
audio_only = bool(payload.get("audio_only", False))
if not urls: if not urls:
return {"ok": False, "error": "Nessun URL fornito"} return {"ok": False, "error": "Nessun URL fornito"}
@@ -648,7 +649,7 @@ class Api:
self._video_thread = threading.Thread( self._video_thread = threading.Thread(
target=self._video_worker, target=self._video_worker,
args=(list(urls), output_dir, quality), args=(list(urls), output_dir, quality, audio_only),
daemon=True, daemon=True,
) )
self._video_thread.start() self._video_thread.start()
@@ -659,10 +660,12 @@ class Api:
self._log("video", "[INFO] Interruzione richiesta...") self._log("video", "[INFO] Interruzione richiesta...")
return {"ok": True} return {"ok": True}
def _video_worker(self, urls: list, output_dir: str, quality: str) -> None: def _video_worker(self, urls: list, output_dir: str, quality: str,
audio_only: bool = False) -> None:
reset_download_stop() reset_download_stop()
cfg = load_config() cfg = load_config()
cookies_path = cfg.get("cookies_path", "") cookies_path = cfg.get("cookies_path", "")
bitrate = cfg.get("bitrate", "320K")
total_urls = len(urls) total_urls = len(urls)
multi = total_urls > 1 multi = total_urls > 1
view = "video" view = "video"
@@ -718,9 +721,11 @@ class Api:
self._emit("video:progress", payload_evt) self._emit("video:progress", payload_evt)
self._log(view, f"[INFO] Video: {url}") mode_lbl = f"Solo audio MP3 {bitrate}" if audio_only else f"Video {quality}"
self._log(view, f"[INFO] {mode_lbl}: {url}")
try: try:
download_video(url, output_dir, quality, cookies_path, progress_cb) download_video(url, output_dir, quality, cookies_path, progress_cb,
audio_only=audio_only, audio_bitrate=bitrate)
except Exception as e: except Exception as e:
self._log(view, f"[ERRORE] {e}") self._log(view, f"[ERRORE] {e}")
continue continue
+9 -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",
@@ -230,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
@@ -238,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}")
@@ -249,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
@@ -272,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()
+9 -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",
@@ -126,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")
# ========================================================================= # =========================================================================
@@ -138,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":
@@ -167,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.2.2" 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
+22 -3
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,6 +487,21 @@ def download_video(
if progress_callback: if progress_callback:
progress_callback(i, total, title, "downloading", 0) progress_callback(i, total, title, "downloading", 0)
if audio_only:
cmd = [
ytdlp,
"-f", fmt,
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", audio_bitrate.rstrip("Kk"),
"--embed-thumbnail",
"--add-metadata",
"--no-check-certificates",
"--newline",
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
entry_url,
]
else:
cmd = [ cmd = [
ytdlp, ytdlp,
"-f", fmt, "-f", fmt,
+86 -21
View File
@@ -29,6 +29,8 @@ _current_proc: Optional[subprocess.Popen] = None
_proc_lock = threading.Lock() _proc_lock = threading.Lock()
_stop_event = threading.Event() _stop_event = threading.Event()
_recording_thread: Optional[threading.Thread] = None _recording_thread: Optional[threading.Thread] = None
_stderr_buf: list = []
_stderr_lock = threading.Lock()
def is_recording() -> bool: def is_recording() -> bool:
@@ -216,6 +218,24 @@ def start_recording(
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate) cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
_stop_event.clear() _stop_event.clear()
with _stderr_lock:
_stderr_buf.clear()
def _drain_stderr(proc):
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
del pipe e per salvare le ultime righe ai fini diagnostici."""
try:
for raw in iter(proc.stderr.readline, b""):
if not raw:
break
line = raw.decode("utf-8", errors="ignore").rstrip()
with _stderr_lock:
_stderr_buf.append(line)
# Mantieni solo le ultime 50 righe per non sforare la RAM
if len(_stderr_buf) > 50:
del _stderr_buf[0]
except Exception:
pass
def _worker(): def _worker():
global _current_proc global _current_proc
@@ -224,16 +244,41 @@ def start_recording(
_current_proc = subprocess.Popen( _current_proc = subprocess.Popen(
cmd, cmd,
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=False, # bytes su stdin per "q" text=False,
) )
drain_t = threading.Thread(target=_drain_stderr,
args=(_current_proc,), daemon=True)
drain_t.start()
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
# primi ~1.5s, segnala errore prima di emettere 'started'.
start = time.monotonic()
opened = False
for _ in range(15):
if _current_proc.poll() is not None:
break
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
with _stderr_lock:
if any("Press [q]" in l for l in _stderr_buf):
opened = True
break
time.sleep(0.1)
rc_early = _current_proc.poll()
if not opened and rc_early is not None:
# ffmpeg e' uscito prima di iniziare a registrare
with _proc_lock:
_current_proc = None
if progress_callback:
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
return
if progress_callback: if progress_callback:
progress_callback("started", {"output_path": str(out)}) progress_callback("started", {"output_path": str(out)})
start = time.monotonic()
# Pubblica un tick al secondo
while True: while True:
if _stop_event.is_set(): if _stop_event.is_set():
break break
@@ -271,12 +316,7 @@ def start_recording(
if rc == 0 or out.exists(): if rc == 0 or out.exists():
progress_callback("stopped", {"output_path": str(out), "seconds": seconds}) progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
else: else:
err = "" progress_callback("error", {"error": _extract_friendly_error(rc)})
try:
err = (_get_last_stderr() or "").strip().splitlines()[-1]
except Exception:
pass
progress_callback("error", {"error": err or f"ffmpeg exit {rc}"})
except Exception as e: except Exception as e:
with _proc_lock: with _proc_lock:
_current_proc = None _current_proc = None
@@ -289,17 +329,42 @@ def start_recording(
return {"ok": True, "output_path": str(out)} return {"ok": True, "output_path": str(out)}
def _get_last_stderr() -> str: def _extract_friendly_error(rc: int) -> str:
"""Tenta di leggere lo stderr residuo del processo corrente.""" """Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
with _proc_lock: Riconosce i casi tipici: permessi microfono, device occupato,
proc = _current_proc device non disponibile, ..."""
if proc is None or proc.stderr is None: with _stderr_lock:
return "" lines = list(_stderr_buf)
try:
data = proc.stderr.read() or b"" text = "\n".join(lines).lower()
return data.decode("utf-8", errors="ignore")
except Exception: if "abort" in text and rc == -6 and sys.platform == "darwin":
return "" return (
"ffmpeg abortito (errore -6): probabilmente l'app non ha il "
"permesso 'Microfono' di macOS. Vai su Preferenze di Sistema "
"→ Privacy e sicurezza → Microfono e abilita Terminal/Python. "
"Poi riavvia l'app."
)
if any(s in text for s in ("input/output error", "errno 22", "device not configured")):
return "Dispositivo non disponibile (riavvia il servizio audio o ricontrolla la selezione)."
if "permission" in text or "not authorized" in text or "not permitted" in text:
return "Permesso microfono negato da macOS. Abilita Python/MusicTools in Privacy → Microfono."
if "no such device" in text or "no such audio device" in text:
return "Dispositivo non trovato. Premi '↻ Aggiorna' e riseleziona."
# Restituisci l'ultima riga utile dello stderr (esclude le righe di banner/progress)
for line in reversed(lines):
l = line.strip()
if not l:
continue
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
return l
if "error" in l.lower() or "fail" in l.lower() or "denied" in l.lower():
return l
return f"ffmpeg exit {rc}"
def stop_recording() -> dict: def stop_recording() -> dict:
+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,
+53 -4
View File
@@ -1,5 +1,5 @@
/* =============================== /* ===============================
MusicDownload — Spotify-like UI MusicTools — Spotify-like UI
=============================== */ =============================== */
:root { :root {
@@ -615,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
=============================== */ =============================== */
+20 -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>
@@ -144,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>
@@ -155,9 +163,12 @@
<div class="hint"> <div class="hint">
<span class="hint-ico">ⓘ</span> <span class="hint-ico">ⓘ</span>
<div>
In modalità <strong>Solo audio</strong> il file viene salvato in MP3 al bitrate impostato in Impostazioni → Download.<br>
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi). Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi).
</div> </div>
</div> </div>
</div>
<div class="card actions"> <div class="card actions">
<button class="btn btn-pink pill btn-lg" id="videoDownloadBtn"> <button class="btn btn-pink pill btn-lg" id="videoDownloadBtn">
@@ -270,7 +281,13 @@
<div class="hint" id="recHint"> <div class="hint" id="recHint">
<span class="hint-ico">ⓘ</span> <span class="hint-ico">ⓘ</span>
Per registrare l'audio di sistema (Spotify/YouTube/ecc.) installa <strong>BlackHole</strong> (gratuito, brew install blackhole-2ch) e poi imposta l'output di sistema su BlackHole. <div>
<strong>Per registrare l'audio di sistema (Spotify/YouTube):</strong>
installa <code>brew install blackhole-2ch</code>, imposta l'uscita audio di macOS su <strong>BlackHole 2ch</strong>, poi seleziona qui sotto <strong>🔄 BlackHole 2ch (loopback)</strong>.
<br><br>
Per sentire l'audio mentre registri serve un <strong>Multi-Output Device</strong> in <em>Configurazione MIDI Audio</em> (BlackHole + altoparlanti) — guida completa nel
<a href="#" id="recOpenGuide">README su GitHub</a>.
</div>
</div> </div>
</div> </div>
+20 -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);
@@ -375,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) {
@@ -396,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");
@@ -474,6 +486,13 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
// ============================================================ // ============================================================
$("#recRefreshBtn").addEventListener("click", refreshRecDevices); $("#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 () => { $("#recBrowseBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.browse_directory(); const path = await window.pywebview.api.browse_directory();
if (path) { if (path) {