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:
workflow_dispatch:
@@ -36,14 +36,14 @@ jobs:
- name: Package .app -> zip
run: |
cd dist
ditto -c -k --sequesterRsrc --keepParent MusicDownload.app ../MusicDownload-macOS.zip
ls -lh ../MusicDownload-macOS.zip
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip
ls -lh ../MusicTools-macOS.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MusicDownload-macOS
path: MusicDownload-macOS.zip
name: MusicTools-macOS
path: MusicTools-macOS.zip
retention-days: 14
# =========================================================
@@ -69,14 +69,14 @@ jobs:
- name: Package -> zip
shell: pwsh
run: |
Compress-Archive -Path dist/MusicDownload/* -DestinationPath MusicDownload-Windows.zip -CompressionLevel Optimal
Get-Item MusicDownload-Windows.zip
Compress-Archive -Path dist/MusicTools/* -DestinationPath MusicTools-Windows.zip -CompressionLevel Optimal
Get-Item MusicTools-Windows.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MusicDownload-Windows
path: MusicDownload-Windows.zip
name: MusicTools-Windows
path: MusicTools-Windows.zip
retention-days: 14
# =========================================================
@@ -90,22 +90,22 @@ jobs:
- name: Download macOS artifact
uses: actions/download-artifact@v4
with:
name: MusicDownload-macOS
name: MusicTools-macOS
path: artifacts/macos
- name: Download Windows artifact
uses: actions/download-artifact@v4
with:
name: MusicDownload-Windows
name: MusicTools-Windows
path: artifacts/windows
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: MusicDownload ${{ github.ref_name }}
name: MusicTools ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
artifacts/macos/MusicDownload-macOS.zip
artifacts/windows/MusicDownload-Windows.zip
artifacts/macos/MusicTools-macOS.zip
artifacts/windows/MusicTools-Windows.zip
+50 -23
View File
@@ -1,4 +1,4 @@
# MusicDownload
# MusicTools
Applicazione desktop per **scaricare musica e video**, **modificare i metadati** dei file audio e **registrare l'audio** da qualsiasi ingresso del computer.
@@ -8,6 +8,8 @@ Sviluppata da **LuZa**.
Repo: <https://github.com/luzadev/musicdownload>
> ℹ️ Il repo Git conserva il nome storico **`musicdownload`** (l'app prima si chiamava così); URL stabili per release già pubblicate e installazioni esistenti.
## Funzionalità
| Tab | Cosa fa |
@@ -75,34 +77,59 @@ Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTu
macOS non espone nativamente l'output audio del sistema. Per registrare quello che esce dagli speaker (es. uno stream Spotify/YouTube), serve un driver virtuale di **loopback**. Il più diffuso è **BlackHole**, gratuito e open source.
### Installazione
### 1. Installazione
```bash
brew install blackhole-2ch
```
### Configurazione
> Subito dopo l'installazione, se non vedi BlackHole nella tab Registra, riavvia il servizio audio:
> ```bash
> sudo killall coreaudiod
> ```
> Si rilancia da solo in pochi secondi, nessun danno. In alternativa, logout/login o riavvio del Mac.
1. **Preferenze di Sistema** → **Audio** → **Uscita** → seleziona **BlackHole 2ch**
2. Apri la tab **● Registra** in MusicDownload e clicca **↻ Aggiorna**
3. Nel dropdown vedrai **🔄 BlackHole 2ch (loopback)** — selezionalo
4. Premi **● REC**, fai partire lo stream, premi **◼ Stop**
### 2. Permesso Microfono
### Vuoi sentire ancora l'audio mentre registri?
Anche se non è un microfono fisico, macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation.
Crea un **Multi-Output Device** in **Audio MIDI Setup**:
Vai su **Preferenze di Sistema → Privacy e sicurezza → Microfono** e abilita l'app da cui lanci MusicTools (Terminal/iTerm in sviluppo, oppure MusicTools.app per la build distribuita). Se non vedi nessuna voce, comparirà un popup la prima volta che premi REC — accetta.
1. Apri **Configurazione MIDI Audio** (`Utility → Audio MIDI Setup`)
2. `+` in basso a sinistra → **Crea dispositivo aggregato a uscita multipla**
3. Seleziona **BlackHole 2ch** + i tuoi altoparlanti/cuffie
4. Imposta questo Multi-Output come uscita di sistema
### 3. Setup A — minimo (registri ma non senti l'audio durante)
Così l'audio va contemporaneamente agli speaker (lo senti) e a BlackHole (viene registrato).
1. Click sull'icona altoparlante nella barra menu → **Output → BlackHole 2ch**
*Oppure: Preferenze di Sistema → Audio → Uscita → BlackHole 2ch*
2. Fai partire lo stream (Spotify/YouTube): non sentirai nulla — è normale, l'audio va a BlackHole
3. App MusicTools → tab **● Registra** → dispositivo **🔄 BlackHole 2ch (loopback)** → **REC**
4. **Stop** e riascolta il file MP3 generato
### 4. Setup B — completo (registri E senti l'audio contemporaneamente, consigliato)
1. Apri **Configurazione MIDI Audio** (`Applicazioni → Utility → Configurazione MIDI Audio`)
2. In basso a sinistra: **+ → Crea dispositivo aggregato a uscita multipla** (Multi-Output Device)
3. Nella colonna a destra, spunta sia **BlackHole 2ch** sia i tuoi **Altoparlanti integrati** (o le cuffie)
4. Imposta **BlackHole 2ch** come **Master Device** (menù a tendina in alto)
5. Abilita **Drift Correction** sulla riga degli altoparlanti
6. (Opzionale) Rinomina il dispositivo aggregato in `Speakers + BlackHole`
7. **Preferenze di Sistema → Audio → Uscita** → seleziona `Speakers + BlackHole`
8. Nella tab Registra dell'app scegli **🔄 BlackHole 2ch (loopback)** — **NON il dispositivo aggregato**, quello è solo per riprodurre
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
### Troubleshooting
| Sintomo | Causa probabile | Fix |
|---|---|---|
| BlackHole non compare nel dropdown | CoreAudio non l'ha ancora caricato dopo l'install | `sudo killall coreaudiod` poi premi **↻ Aggiorna** |
| Errore `ffmpeg exit -6` o `permesso Microfono mancante` | macOS nega l'accesso al device | Privacy e sicurezza → Microfono → abilita Terminal/MusicTools |
| File MP3 muto | Output di sistema non passa per BlackHole | Cambia output su BlackHole o Multi-Output Device (vedi Setup A/B) |
| Audio doppio/eco nel file | Stai registrando da BlackHole *e* da microfono fisico simultaneamente | Verifica che nel dropdown ci sia solo `BlackHole 2ch` |
| Audio crackly/distorto | Sample rate non allineato | In Audio MIDI Setup, BlackHole + altoparlanti tutti a 48 kHz |
## Struttura progetto
```
MusicDownload/
MusicTools/
├── main.py # Entry point — bootstrap pywebview
├── requirements.txt
├── build_macos.py # Script build macOS (.app)
@@ -144,8 +171,8 @@ Il file `config.json` viene creato automaticamente al primo avvio:
Posizione:
- **Sviluppo**: directory del progetto
- **macOS bundle**: `~/Library/Application Support/MusicDownload/`
- **Windows bundle**: `%APPDATA%/MusicDownload/`
- **macOS bundle**: `~/Library/Application Support/MusicTools/`
- **Windows bundle**: `%APPDATA%/MusicTools/`
## Build locale
@@ -157,7 +184,7 @@ pip install pyinstaller
python3 build_macos.py
```
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
Produce `dist/MusicTools.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
### Windows
@@ -166,14 +193,14 @@ pip install pyinstaller
python build_windows.py
```
Produce `dist/MusicDownload/MusicDownload.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build.
Produce `dist/MusicTools/MusicTools.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build.
## Build & Release automatiche (GitHub Actions)
Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`:
1. **`build-macos`** (runner `macos-latest`) → `MusicDownload-macOS.zip` (la `.app` zippata con `ditto`)
2. **`build-windows`** (runner `windows-latest`) → `MusicDownload-Windows.zip`
1. **`build-macos`** (runner `macos-latest`) → `MusicTools-macOS.zip` (la `.app` zippata con `ditto`)
2. **`build-windows`** (runner `windows-latest`) → `MusicTools-Windows.zip`
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
Per pubblicare una nuova versione:
@@ -186,10 +213,10 @@ git push origin v1.3.0
Per fare solo una build di test (senza release):
```bash
gh workflow run "Build MusicDownload" --ref main
gh workflow run "Build MusicTools" --ref main
```
oppure dalla UI: **Actions → Build MusicDownload → Run workflow**.
oppure dalla UI: **Actions → Build MusicTools → Run workflow**.
Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>.
+11 -6
View File
@@ -38,7 +38,7 @@ SPOTIFY_GUIDE_TEXT = """\
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
2) Clicca "Create app".
3) Compila:
- App name: MusicDownload (o quello che vuoi)
- App name: MusicTools (o quello che vuoi)
- Description: a piacere
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
- Seleziona "Web API"
@@ -148,7 +148,7 @@ class Api:
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
try:
resp = requests.get(API_URL, headers={
"User-Agent": "MusicDownload",
"User-Agent": "MusicTools",
"Accept": "application/vnd.github+json",
}, timeout=10)
resp.raise_for_status()
@@ -640,6 +640,7 @@ class Api:
urls = payload.get("urls") or []
output_dir = (payload.get("output_dir") or "").strip()
quality = payload.get("quality") or "1080p"
audio_only = bool(payload.get("audio_only", False))
if not urls:
return {"ok": False, "error": "Nessun URL fornito"}
@@ -648,7 +649,7 @@ class Api:
self._video_thread = threading.Thread(
target=self._video_worker,
args=(list(urls), output_dir, quality),
args=(list(urls), output_dir, quality, audio_only),
daemon=True,
)
self._video_thread.start()
@@ -659,10 +660,12 @@ class Api:
self._log("video", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _video_worker(self, urls: list, output_dir: str, quality: str) -> None:
def _video_worker(self, urls: list, output_dir: str, quality: str,
audio_only: bool = False) -> None:
reset_download_stop()
cfg = load_config()
cookies_path = cfg.get("cookies_path", "")
bitrate = cfg.get("bitrate", "320K")
total_urls = len(urls)
multi = total_urls > 1
view = "video"
@@ -718,9 +721,11 @@ class Api:
self._emit("video:progress", payload_evt)
self._log(view, f"[INFO] Video: {url}")
mode_lbl = f"Solo audio MP3 {bitrate}" if audio_only else f"Video {quality}"
self._log(view, f"[INFO] {mode_lbl}: {url}")
try:
download_video(url, output_dir, quality, cookies_path, progress_cb)
download_video(url, output_dir, quality, cookies_path, progress_cb,
audio_only=audio_only, audio_bitrate=bitrate)
except Exception as e:
self._log(view, f"[ERRORE] {e}")
continue
+9 -9
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Build script per creare MusicDownload.app su macOS.
Build script per creare MusicTools.app su macOS.
Uso:
python3 build_macos.py
@@ -8,7 +8,7 @@ Uso:
Cosa fa:
1. Scarica il binario standalone di yt-dlp per macOS
2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew
3. Esegue PyInstaller per creare MusicDownload.app
3. Esegue PyInstaller per creare MusicTools.app
Requisiti:
pip3 install pyinstaller
@@ -210,7 +210,7 @@ def run_pyinstaller():
cmd = [
sys.executable, "-m", "PyInstaller",
"--name", "MusicDownload",
"--name", "MusicTools",
"--windowed",
"--onedir",
"--noconfirm",
@@ -230,7 +230,7 @@ def run_pyinstaller():
log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT))
app_path = DIST_DIR / "MusicDownload.app"
app_path = DIST_DIR / "MusicTools.app"
if app_path.exists():
log(f"Build completata: {app_path}")
# Mostra dimensione
@@ -238,8 +238,8 @@ def run_pyinstaller():
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
else:
log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller")
# Potrebbe essere in dist/MusicDownload/ (non .app)
alt = DIST_DIR / "MusicDownload"
# Potrebbe essere in dist/MusicTools/ (non .app)
alt = DIST_DIR / "MusicTools"
if alt.exists():
log(f"Trovata directory: {alt}")
@@ -249,7 +249,7 @@ def run_pyinstaller():
# =========================================================================
def main():
print("=" * 50)
print(" MusicDownload — Build macOS")
print(" MusicTools — Build macOS")
print("=" * 50)
# Verifica pyinstaller
@@ -272,12 +272,12 @@ def main():
print("\n" + "=" * 50)
print(" DONE!")
print("=" * 50)
app = DIST_DIR / "MusicDownload.app"
app = DIST_DIR / "MusicTools.app"
if app.exists():
print(f"\n L'app e in: {app}")
print(f" Per testarla: open '{app}'")
print(f"\n Per distribuirla, comprimi la cartella:")
print(f" zip -r MusicDownload.zip dist/MusicDownload.app")
print(f" zip -r MusicTools.zip dist/MusicTools.app")
print()
+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:
python build_windows.py
@@ -7,7 +7,7 @@ Uso:
Cosa fa:
1. Scarica il binario standalone di yt-dlp per Windows
2. Scarica ffmpeg static build per Windows
3. Esegue PyInstaller per creare MusicDownload.exe
3. Esegue PyInstaller per creare MusicTools.exe
Requisiti:
pip install pyinstaller
@@ -101,7 +101,7 @@ def run_pyinstaller():
cmd = [
sys.executable, "-m", "PyInstaller",
"--name", "MusicDownload",
"--name", "MusicTools",
"--windowed",
"--onedir",
"--noconfirm",
@@ -126,11 +126,11 @@ def run_pyinstaller():
log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT))
exe_path = DIST_DIR / "MusicDownload" / "MusicDownload.exe"
exe_path = DIST_DIR / "MusicTools" / "MusicTools.exe"
if exe_path.exists():
log(f"Build completata: {exe_path}")
else:
log("ATTENZIONE: MusicDownload.exe non trovato, controlla l'output")
log("ATTENZIONE: MusicTools.exe non trovato, controlla l'output")
# =========================================================================
@@ -138,7 +138,7 @@ def run_pyinstaller():
# =========================================================================
def main():
print("=" * 50)
print(" MusicDownload - Build Windows")
print(" MusicTools - Build Windows")
print("=" * 50)
if sys.platform != "win32":
@@ -167,12 +167,12 @@ def main():
print("\n" + "=" * 50)
print(" DONE!")
print("=" * 50)
dist = DIST_DIR / "MusicDownload"
dist = DIST_DIR / "MusicTools"
if dist.exists():
print(f"\n L'app e in: {dist}")
print(f" Eseguibile: {dist / 'MusicDownload.exe'}")
print(f" Eseguibile: {dist / 'MusicTools.exe'}")
print(f"\n Per distribuirla, comprimi la cartella:")
print(f" Comprimi {dist} -> MusicDownload_Windows.zip")
print(f" Comprimi {dist} -> MusicTools_Windows.zip")
print()
+22 -4
View File
@@ -5,24 +5,42 @@ import os
import sys
from pathlib import Path
VERSION = "v1.2.2"
VERSION = "v1.4.0"
APP_NAME = "MusicTools"
_LEGACY_NAME = "MusicDownload"
def _get_config_dir() -> Path:
"""Ritorna la directory per config.json.
Frozen (bundle PyInstaller):
- macOS: ~/Library/Application Support/MusicDownload/
- Windows: %APPDATA%/MusicDownload/
- macOS: ~/Library/Application Support/MusicTools/
- Windows: %APPDATA%/MusicTools/
Dev (non frozen):
- directory del progetto
Se la cartella nuova non esiste ma quella legacy 'MusicDownload' si',
viene migrata (copia di config.json) cosi' l'utente non perde le
impostazioni dopo il rename.
"""
if getattr(sys, "frozen", False):
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", Path.home()))
else:
base = Path.home() / "Library" / "Application Support"
config_dir = base / "MusicDownload"
config_dir = base / APP_NAME
legacy_dir = base / _LEGACY_NAME
# Migrazione one-shot: se non esiste la nuova ma esiste la vecchia
if not config_dir.exists() and legacy_dir.exists():
try:
config_dir.mkdir(parents=True, exist_ok=True)
legacy_cfg = legacy_dir / "config.json"
if legacy_cfg.exists():
(config_dir / "config.json").write_bytes(legacy_cfg.read_bytes())
except OSError:
pass
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
return Path(__file__).resolve().parent.parent
+22 -3
View File
@@ -418,10 +418,14 @@ def download_video(
quality: str = "1080p",
cookies_path: Optional[str] = None,
progress_callback: Optional[Callable] = None,
audio_only: bool = False,
audio_bitrate: str = "320K",
) -> None:
"""Scarica video (con audio) da YouTube/TikTok/Instagram/Facebook/etc.
"""Scarica video (con audio) o solo audio da YouTube/TikTok/Instagram/Facebook/etc.
quality: "best" | "1080p" | "720p" | "480p"
quality: "best" | "1080p" | "720p" | "480p" (ignorato se audio_only=True)
audio_only: True -> estrae solo audio in MP3
audio_bitrate: bitrate MP3 quando audio_only=True
Per Instagram/Facebook privati servono cookies validi.
"""
global _current_process
@@ -461,7 +465,7 @@ def download_video(
return
total = len(entries)
fmt = _video_format(quality)
fmt = _video_format(quality) if not audio_only else "bestaudio/best"
for i, entry in enumerate(entries):
if is_stopped():
@@ -483,6 +487,21 @@ def download_video(
if progress_callback:
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 = [
ytdlp,
"-f", fmt,
+86 -21
View File
@@ -29,6 +29,8 @@ _current_proc: Optional[subprocess.Popen] = None
_proc_lock = threading.Lock()
_stop_event = threading.Event()
_recording_thread: Optional[threading.Thread] = None
_stderr_buf: list = []
_stderr_lock = threading.Lock()
def is_recording() -> bool:
@@ -216,6 +218,24 @@ def start_recording(
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
_stop_event.clear()
with _stderr_lock:
_stderr_buf.clear()
def _drain_stderr(proc):
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
del pipe e per salvare le ultime righe ai fini diagnostici."""
try:
for raw in iter(proc.stderr.readline, b""):
if not raw:
break
line = raw.decode("utf-8", errors="ignore").rstrip()
with _stderr_lock:
_stderr_buf.append(line)
# Mantieni solo le ultime 50 righe per non sforare la RAM
if len(_stderr_buf) > 50:
del _stderr_buf[0]
except Exception:
pass
def _worker():
global _current_proc
@@ -224,16 +244,41 @@ def start_recording(
_current_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=False, # bytes su stdin per "q"
text=False,
)
drain_t = threading.Thread(target=_drain_stderr,
args=(_current_proc,), daemon=True)
drain_t.start()
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
# primi ~1.5s, segnala errore prima di emettere 'started'.
start = time.monotonic()
opened = False
for _ in range(15):
if _current_proc.poll() is not None:
break
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
with _stderr_lock:
if any("Press [q]" in l for l in _stderr_buf):
opened = True
break
time.sleep(0.1)
rc_early = _current_proc.poll()
if not opened and rc_early is not None:
# ffmpeg e' uscito prima di iniziare a registrare
with _proc_lock:
_current_proc = None
if progress_callback:
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
return
if progress_callback:
progress_callback("started", {"output_path": str(out)})
start = time.monotonic()
# Pubblica un tick al secondo
while True:
if _stop_event.is_set():
break
@@ -271,12 +316,7 @@ def start_recording(
if rc == 0 or out.exists():
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
else:
err = ""
try:
err = (_get_last_stderr() or "").strip().splitlines()[-1]
except Exception:
pass
progress_callback("error", {"error": err or f"ffmpeg exit {rc}"})
progress_callback("error", {"error": _extract_friendly_error(rc)})
except Exception as e:
with _proc_lock:
_current_proc = None
@@ -289,17 +329,42 @@ def start_recording(
return {"ok": True, "output_path": str(out)}
def _get_last_stderr() -> str:
"""Tenta di leggere lo stderr residuo del processo corrente."""
with _proc_lock:
proc = _current_proc
if proc is None or proc.stderr is None:
return ""
try:
data = proc.stderr.read() or b""
return data.decode("utf-8", errors="ignore")
except Exception:
return ""
def _extract_friendly_error(rc: int) -> str:
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
Riconosce i casi tipici: permessi microfono, device occupato,
device non disponibile, ..."""
with _stderr_lock:
lines = list(_stderr_buf)
text = "\n".join(lines).lower()
if "abort" in text and rc == -6 and sys.platform == "darwin":
return (
"ffmpeg abortito (errore -6): probabilmente l'app non ha il "
"permesso 'Microfono' di macOS. Vai su Preferenze di Sistema "
"→ Privacy e sicurezza → Microfono e abilita Terminal/Python. "
"Poi riavvia l'app."
)
if any(s in text for s in ("input/output error", "errno 22", "device not configured")):
return "Dispositivo non disponibile (riavvia il servizio audio o ricontrolla la selezione)."
if "permission" in text or "not authorized" in text or "not permitted" in text:
return "Permesso microfono negato da macOS. Abilita Python/MusicTools in Privacy → Microfono."
if "no such device" in text or "no such audio device" in text:
return "Dispositivo non trovato. Premi '↻ Aggiorna' e riseleziona."
# Restituisci l'ultima riga utile dello stderr (esclude le righe di banner/progress)
for line in reversed(lines):
l = line.strip()
if not l:
continue
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
return l
if "error" in l.lower() or "fail" in l.lower() or "denied" in l.lower():
return l
return f"ffmpeg exit {rc}"
def stop_recording() -> dict:
+2 -2
View File
@@ -1,4 +1,4 @@
"""MusicDownload — Entry point (pywebview + WebKit/EdgeChromium)."""
"""MusicTools — Entry point (pywebview + WebKit/EdgeChromium)."""
import os
import sys
@@ -23,7 +23,7 @@ def main():
index_path = _resource_path("webui", "index.html")
window = webview.create_window(
title="MusicDownload",
title="MusicTools",
url=index_path,
js_api=api,
width=1080,
+53 -4
View File
@@ -1,5 +1,5 @@
/* ===============================
MusicDownload — Spotify-like UI
MusicTools — Spotify-like UI
=============================== */
:root {
@@ -615,30 +615,79 @@ input[type="number"]::-webkit-inner-spin-button {
.log .l-warn { color: #FDCB6E; }
.log .l-muted { color: var(--text-3); }
/* ===============================
SEGMENTED CONTROL
=============================== */
.segmented {
display: inline-flex;
background: var(--bg-input);
padding: 4px;
border-radius: var(--r-pill);
gap: 2px;
}
.seg-btn {
border: none;
background: transparent;
color: var(--text-2);
padding: 8px 18px;
border-radius: var(--r-pill);
cursor: pointer;
font-weight: 700;
font-size: 13px;
transition: background-color 0.15s, color 0.15s;
}
.seg-btn:hover:not(.active) { color: var(--text); }
.seg-btn.active {
background: var(--pink);
color: #fff;
}
/* ===============================
HINT BOX
=============================== */
.hint {
display: flex;
align-items: center;
gap: 10px;
align-items: flex-start;
gap: 12px;
margin-top: 4px;
padding: 10px 14px;
padding: 12px 14px;
background: rgba(236, 72, 153, 0.08);
border: 1px solid rgba(236, 72, 153, 0.2);
border-radius: var(--r-sm);
color: var(--text-2);
font-size: 12.5px;
line-height: 1.55;
}
.hint-ico {
color: var(--pink-hover);
font-size: 14px;
font-weight: 800;
margin-top: 1px;
}
.hint strong { color: var(--text); font-weight: 700; }
.hint code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
color: var(--text);
}
.hint a {
color: var(--green-hover);
text-decoration: none;
font-weight: 700;
}
.hint a:hover { text-decoration: underline; }
/* ===============================
CHECKBOX
=============================== */
+20 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MusicDownload</title>
<title>MusicTools</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
@@ -15,7 +15,7 @@
<div class="brand-logo">
<span>♫</span>
</div>
<div class="brand-name">MusicDownload</div>
<div class="brand-name">MusicTools</div>
</div>
<div class="divider"></div>
@@ -144,6 +144,14 @@
</div>
<div class="field inline">
<label class="field-label">Modalità</label>
<div class="segmented" id="videoMode">
<button type="button" class="seg-btn active" data-mode="video">🎬 Video (MP4)</button>
<button type="button" class="seg-btn" data-mode="audio">🎵 Solo audio (MP3)</button>
</div>
</div>
<div class="field inline" id="videoQualityField">
<label class="field-label">Qualità</label>
<select id="videoQuality" class="input pill input-sm">
<option value="best">Migliore disponibile</option>
@@ -155,9 +163,12 @@
<div class="hint">
<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).
</div>
</div>
</div>
<div class="card actions">
<button class="btn btn-pink pill btn-lg" id="videoDownloadBtn">
@@ -270,7 +281,13 @@
<div class="hint" id="recHint">
<span class="hint-ico">ⓘ</span>
Per registrare l'audio di sistema (Spotify/YouTube/ecc.) installa <strong>BlackHole</strong> (gratuito, brew install blackhole-2ch) e poi imposta l'output di sistema su BlackHole.
<div>
<strong>Per registrare l'audio di sistema (Spotify/YouTube):</strong>
installa <code>brew install blackhole-2ch</code>, imposta l'uscita audio di macOS su <strong>BlackHole 2ch</strong>, poi seleziona qui sotto <strong>🔄 BlackHole 2ch (loopback)</strong>.
<br><br>
Per sentire l'audio mentre registri serve un <strong>Multi-Output Device</strong> in <em>Configurazione MIDI Audio</em> (BlackHole + altoparlanti) — guida completa nel
<a href="#" id="recOpenGuide">README su GitHub</a>.
</div>
</div>
</div>
+20 -1
View File
@@ -1,5 +1,5 @@
/* ======================================================
MusicDownload — Frontend (vanilla JS)
MusicTools — Frontend (vanilla JS)
====================================================== */
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 () => {
const url = $("#videoUrlInput").value.trim();
if (!url) {
@@ -396,6 +407,7 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
urls: [url],
output_dir: state.videoOutputDir,
quality: $("#videoQuality").value,
audio_only: videoMode === "audio",
});
if (!res.ok) {
toast(res.error || "Errore", "error");
@@ -474,6 +486,13 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
// ============================================================
$("#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) {