Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07794052c8 | ||
|
|
850d3f4b6c | ||
|
|
6f5c32165a | ||
|
|
39c92f44f4 | ||
|
|
9f37b7eb94 |
No files matched your search
+14
-14
@@ -1,4 +1,4 @@
|
|||||||
name: Build MusicDownload
|
name: Build MusicTools
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -36,14 +36,14 @@ jobs:
|
|||||||
- name: Package .app -> zip
|
- name: Package .app -> zip
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
cd dist
|
||||||
ditto -c -k --sequesterRsrc --keepParent MusicDownload.app ../MusicDownload-macOS.zip
|
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip
|
||||||
ls -lh ../MusicDownload-macOS.zip
|
ls -lh ../MusicTools-macOS.zip
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicDownload-macOS
|
name: MusicTools-macOS
|
||||||
path: MusicDownload-macOS.zip
|
path: MusicTools-macOS.zip
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -69,14 +69,14 @@ jobs:
|
|||||||
- name: Package -> zip
|
- name: Package -> zip
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
Compress-Archive -Path dist/MusicDownload/* -DestinationPath MusicDownload-Windows.zip -CompressionLevel Optimal
|
Compress-Archive -Path dist/MusicTools/* -DestinationPath MusicTools-Windows.zip -CompressionLevel Optimal
|
||||||
Get-Item MusicDownload-Windows.zip
|
Get-Item MusicTools-Windows.zip
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicDownload-Windows
|
name: MusicTools-Windows
|
||||||
path: MusicDownload-Windows.zip
|
path: MusicTools-Windows.zip
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -90,22 +90,22 @@ jobs:
|
|||||||
- name: Download macOS artifact
|
- name: Download macOS artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicDownload-macOS
|
name: MusicTools-macOS
|
||||||
path: artifacts/macos
|
path: artifacts/macos
|
||||||
|
|
||||||
- name: Download Windows artifact
|
- name: Download Windows artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicDownload-Windows
|
name: MusicTools-Windows
|
||||||
path: artifacts/windows
|
path: artifacts/windows
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
name: MusicDownload ${{ github.ref_name }}
|
name: MusicTools ${{ github.ref_name }}
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
files: |
|
files: |
|
||||||
artifacts/macos/MusicDownload-macOS.zip
|
artifacts/macos/MusicTools-macOS.zip
|
||||||
artifacts/windows/MusicDownload-Windows.zip
|
artifacts/windows/MusicTools-Windows.zip
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user