Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5041d3e14c | ||
|
|
97a440819a | ||
|
|
1d7832e246 | ||
|
|
61879c42d3 | ||
|
|
246ff1b0e5 | ||
|
|
3dcbdfa489 | ||
|
|
946452ef03 | ||
|
|
d8423ec215 | ||
|
|
4b1524e5f7 | ||
|
|
e5158d382f |
No files matched your search
@@ -0,0 +1,100 @@
|
||||
1. Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)
|
||||
2. Brunello, Vargas – Ghost Dance (Original Mix) (6:12)
|
||||
3. Hardt Antoine – Raw (Original Mix) (5:25)
|
||||
4. JUNO (DE) & Samm (BE) – Heart Spin (Original Mix) (5:56)
|
||||
5. AIKON – To You (Original Mix) (6:12)
|
||||
6. Adam Port, Keinemusik & SG Lewis – Be The One (Extended) (4:45)
|
||||
7. HotLap – Recall (Extended Mix) (5:48)
|
||||
8. JUNO (DE) & Samm (BE) – Muralli (Original Mix) (4:59)
|
||||
9. Brunello – Science Fiction (Original Mix) (6:36)
|
||||
10. Emanuel Satie, Tim Engelhardt, Maga, Sean Doron, Unfazed, Scenarios – A Gira (Extended Remix) (5:28)
|
||||
11. DJ Koze – Spiralen (Original Mix) (7:17)
|
||||
12. Joris Voorn, Taylr Renee & Roddy Lima – Machine (Original Mix) (5:02)
|
||||
13. KREAM & Korolova – Annihilation (Extended Mix) (4:50)
|
||||
14. Bedouin, Karl Williams, LUCH – The Only One (Original Mix) (6:49)
|
||||
15. Massano & Silver Panda – Pa Ca (Original Mix) (4:47)
|
||||
16. Moeaike, Arodes – Nobody Else (Extended Mix) (5:58)
|
||||
17. RÜFÜS DU SOL – In the Moment (Adriatique Extended Remix) (5:12)
|
||||
18. &ME, Black Coffee – The Rapture Pt.III (Original Mix) (8:02)
|
||||
19. Liva K – Didn’t Miss You (Original Mix) (5:46)
|
||||
20. Carlo Whale & TH;EN – Echoes of You (Original Mix) (5:22)
|
||||
21. Magnus, JEWELS, YUMA., SOMMA, LE YORA – EVERYTHING IN ITS RIGHT PLACE (Original Mix) (7:14)
|
||||
22. Andrea Oliva – Neon Hearts (Original Mix) (6:06)
|
||||
23. Jackie Hollander – High On You (Extended Mix) (4:57)
|
||||
24. KREAM, SCRIPT – Turn Up The Dose (Extended Mix) (4:20)
|
||||
25. JOSEPH (CH) – B4U (Original Mix) (8:00)
|
||||
26. Ajna (BE), Misha – Body Moving (Original Mix) (6:16)
|
||||
27. Green Velvet & Harvard Bass – Lazer Beams (Adam Beyer & Massano Remix) (5:02)
|
||||
28. Argy, Omiki & Lell Nahar – MANTRA (Extended Mix) (5:03)
|
||||
29. Sasha & Franky Wah – Get On My Level (Original Mix) (8:00)
|
||||
30. LUCH – UH HUH (Original Mix) (6:45)
|
||||
31. Fahlberg – Feeling Good (Extended Mix) (6:49)
|
||||
32. Ivory (IT) – Back Again (Original Mix) (5:42)
|
||||
33. Massano, Y Do I – Lost My Mind (Original Mix) (5:23)
|
||||
34. KOWSKI, Eric Reyes – Break Down! (Original Mix) (5:30)
|
||||
35. Andrea Oliva – Spotlight (Original Mix) (6:21)
|
||||
36. Tiga vs Meduza – You Gonna Want Me (I Know) (Extended Mix) (5:21)
|
||||
37. Marsh – Beside (Extended Mix) (6:18)
|
||||
38. Moderat, &ME, Rampa & Keinemusik – More Love (Rampa &ME Remix) (4:55)
|
||||
39. Adriatique & Emmit Fenn – Closer (Extended Mix) (4:53)
|
||||
40. Jimi Jules, Black Coffee – Trippy Yeah (Extended Mix) (6:40)
|
||||
41. Liva K & OVEOUS – Blessings (Original Mix) (6:35)
|
||||
42. Township Rebellion, Kasablanca – It’s Chemical (Original Mix) (6:28)
|
||||
43. MAXI MERAKI, Sharing-DB.club – First Time (Original Mix) (5:19)
|
||||
44. Pole Position – Touch Me (Hotlap Remix) (5:40)
|
||||
45. Da Hool, Cassian & YOTTO – Love Parade (Extended Mix) (4:09)
|
||||
46. Jody Wisternoff, Jamie McDermott – Citadel (Extended Mix) (5:17)
|
||||
47. Super Flu, Dennis Louvra – Can’t Escape (Original Mix) (5:56)
|
||||
48. Armin van Buuren, Argy, Marlo Rex – Like A Child (Extended Mix) (4:08)
|
||||
49. Robert Babicz – The Wave (Original Mix) (5:08)
|
||||
50. The Temper Trap – Sweet Disposition (John Summit & Silver Panda Extended Remix) (5:44)
|
||||
51. Yotto, Tallac – Chemtrail Surfers (Extended Mix) (6:26)
|
||||
52. HotLap – Waitin’ (Extended Mix) (6:35)
|
||||
53. Miss Monique, Nicolas Taboada & FRANCO BA – Ghosts (Extended Mix) (5:28)
|
||||
54. Picca & Mars & Moeaike – Move in Time (Original Mix) (5:39)
|
||||
55. Hardt Antoine – Sing It Back (Extended Mix) (6:57)
|
||||
56. Shakedown, Layton Giordani & Anyma (ofc) – At Night (Anyma x Layton Giordani Extended Remix) (4:35)
|
||||
57. Acrobatik & whatlewisdoes – Lamazi (Extended Mix) (4:46)
|
||||
58. Massano – Abraza (Original Mix) (5:13)
|
||||
59. Alcatraz – Giv Me Luv (Jerome Isma-Ae Remix) (7:18)
|
||||
60. Gespona – Komplex (Original Mix) (5:56)
|
||||
61. Misha & Martim Rola – On Business (Extended Mix) (6:30)
|
||||
62. Mind Against, Anakim & HUMAN404 – Late at Night (Extended Mix) (4:39)
|
||||
63. Anyma (ofc), Argy & Son of Son – Voices In My Head (Extended Mix) (4:16)
|
||||
64. Fred again.., Swedish House Mafia, Future – Turn On The Lights again.. (feat. Future) (Original Mix) (4:27)
|
||||
65. Sezer Uysal & Axelax – Calling For More (Original Mix) (5:12)
|
||||
66. LUCH, Marten Lou – Hideaway (Extended Version) (5:57)
|
||||
67. Giuseppe Ottaviani, ARTBAT & Conor Ross – Break The Loop (Original Mix) (5:22)
|
||||
68. Rhodes, CamelPhat, ARTBAT – For a Feeling (Adam Sellouk & Y do I Remix – Extended) (4:49)
|
||||
69. Monolink, Artbat – Return to Oz (ARTBAT Remix) (8:00)
|
||||
70. Lisa & Anyma (ofc) – Bad Angel (with Lisa) (Extended Mix) (4:08)
|
||||
71. Florist (BE) – Unite (Original Mix) (6:25)
|
||||
72. Moderat – Eating Hooks (Deep Dish Extended Remix) (7:13)
|
||||
73. CamelPhat & Arodes – Cycles (Extended Mix) (6:52)
|
||||
74. Interplanetary Criminal, Kettama – Yosemite (Extended) (6:02)
|
||||
75. Leblanc (ofc) – Strings Anthem (Original Mix) (5:22)
|
||||
76. Mind Against & Jos & Eli – Overture (Original Mix) (5:21)
|
||||
77. Tripolism & Kingstarlight – Hurricane (Extended Mix) (4:49)
|
||||
78. Luke Alexander – Last Night (feat. Y4MI) (Original Mix) (3:30)
|
||||
79. Braxton – There’s Still Time (Extended Mix) (6:31)
|
||||
80. Township Rebellion – Birds Fly First Class (Original Mix) (6:19)
|
||||
81. Agents Of Time & Miss Monique – Rajada (Original Mix) (5:00)
|
||||
82. Fancy Inc – Hypnotic (Extended Mix) (5:09)
|
||||
83. Boys Noize, &ME, Rampa, Adam Port, Keinemusik, Vinson – Crazy For It (feat. Vinson) (Original Mix) (6:01)
|
||||
84. Space Motion & Luca Belluco – Baila Conmigo (Original Mix) (4:38)
|
||||
85. Tom Westy & Dansyn – Feel Your Soul (Extended) (4:33)
|
||||
86. CamelPhat & Josh Gigante – The One (Extended Mix) (5:32)
|
||||
87. Argy & Omnya – Aria (Extended Mix) (5:14)
|
||||
88. Air & Tur, blou., Airsand, TuraniQa – Teach Me (Extended Mix) (4:35)
|
||||
89. Dilby, Martin Ka – Moving Trough (Extened Mix) (6:16)
|
||||
90. Pablo Say – Repique (Original Mix) (4:48)
|
||||
91. Orsay & Kotiēr – 1886 (Original Mix) (5:46)
|
||||
92. Adriatique & Elderbrook – Lost In The Woods (Extended Mix) (5:01)
|
||||
93. Ajna (BE) & Sentin – Bringing The Noise (Original Mix) (5:45)
|
||||
94. Eric Prydz – Pjanoo (Club Mix) (7:30)
|
||||
95. Eelke Kleijn – Feel It, See It (Extended Mix) (6:07)
|
||||
96. Aera – Litoral (Original Mix) (6:10)
|
||||
97. Julian Koerndl – Horses (Original Mix) (6:09)
|
||||
98. Supermode – Tell Me Why (MEDUZA Extended Remix) (8:28)
|
||||
99. Laherte – Pump Up The Jam (Extended Mix) (3:58)
|
||||
100. M.O.S. – Changes (Extended Mix) (6:43)
|
||||
@@ -1,98 +1,153 @@
|
||||
# MusicDownload
|
||||
|
||||
Applicazione desktop per scaricare musica da Spotify, YouTube, SoundCloud e qualsiasi sorgente supportata da yt-dlp, con possibilita di migliorare la qualita audio dei file esistenti.
|
||||
Applicazione desktop per **scaricare musica e video**, **modificare i metadati** dei file audio e **registrare l'audio** da qualsiasi ingresso del computer.
|
||||
|
||||
UI moderna basata su **pywebview** (WebKit su macOS, EdgeChromium su Windows) — frontend HTML/CSS/JS, backend Python.
|
||||
|
||||
Sviluppata da **LuZa**.
|
||||
|
||||
## Funzionalita
|
||||
Repo: <https://github.com/luzadev/musicdownload>
|
||||
|
||||
- **Download da Spotify** — Incolla un link a playlist, album o brano singolo: l'app risolve i brani via API Spotify e li scarica da YouTube in MP3
|
||||
- **Download diretto** — Incolla un URL YouTube, SoundCloud o qualsiasi sorgente yt-dlp e scarica direttamente in MP3 (singoli video e playlist)
|
||||
- **Upgrade qualita** — Scansiona una cartella di file audio e riscarica quelli a bassa qualita da YouTube
|
||||
- **Aggiornamento app** — Controlla la disponibilita di nuove versioni direttamente dalle Impostazioni
|
||||
- **Gestione impostazioni** — Credenziali Spotify, bitrate, soglia HQ, percorsi, tema
|
||||
## Funzionalità
|
||||
|
||||
| Tab | Cosa fa |
|
||||
|---|---|
|
||||
| **⬇ Scarica** | Spotify (playlist/album/brano) · YouTube · SoundCloud · lista URL `.txt` · tracklist `.txt` con titoli (es. Beatport Top 100) |
|
||||
| **🎬 Video** | YouTube · TikTok · Instagram · Facebook (e 1000+ siti yt-dlp) in MP4, qualità best/1080p/720p/480p |
|
||||
| **⚡ Upgrade** | Scansiona una cartella audio e riscarica i file sotto la soglia kbps impostata |
|
||||
| **🏷 Metadati** | Editor tag ID3 / MP4 / Vorbis (MP3, M4A, FLAC, WAV) — titolo, artista, album, anno, copertina, BPM, key, commento, origine (macOS) |
|
||||
| **● Registra** | Cattura audio in MP3 320 kbps da qualsiasi ingresso (mixer, scheda audio, microfono, BlackHole/loopback) |
|
||||
| **⚙ Impostazioni** | Credenziali Spotify, bitrate, soglia HQ, percorsi, tema, controllo aggiornamenti |
|
||||
|
||||
### Auto-skip dei duplicati
|
||||
|
||||
Tracking persistente (`.downloaded_tracks` / `.downloaded_videos`) + scan filename token-based: re-eseguire la stessa playlist salta i brani già presenti, anche se il filename ha `Official Video` / `Lyrics` / ecc.
|
||||
|
||||
### Sottocartella automatica per le tracklist
|
||||
|
||||
Se carichi un file `.txt` con titoli (es. `Beatport Top 100 Melodic House & Techno.txt`), l'app crea automaticamente una sottocartella `<output_dir>/Beatport Top 100 Melodic House & Techno/` per non mischiare brani di playlist diverse.
|
||||
|
||||
## Requisiti
|
||||
|
||||
- Python 3.8+
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) installato nel sistema (`brew install yt-dlp` su macOS)
|
||||
- [ffmpeg](https://ffmpeg.org/) installato nel sistema (`brew install ffmpeg` su macOS)
|
||||
- Credenziali Spotify API (gratuite, vedi sotto) — necessarie solo per URL Spotify
|
||||
- `ffmpeg` + `ffprobe` (solo per build/sviluppo locale: `brew install ffmpeg` su macOS)
|
||||
- Credenziali Spotify API gratuite — solo se si usano URL Spotify
|
||||
|
||||
## Installazione
|
||||
Nelle build distribuite (`.app` / `.exe`) **tutto è incluso** — non serve installare nulla.
|
||||
|
||||
## Installazione (sviluppo)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/luzadev/musicdownload.git
|
||||
cd musicdownload
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Dipendenze Python:
|
||||
|
||||
| Pacchetto | Utilizzo |
|
||||
|---|---|
|
||||
| `customtkinter` | GUI moderna basata su Tkinter |
|
||||
| `requests` | Chiamate API Spotify |
|
||||
| `yt-dlp` | Download audio da YouTube |
|
||||
| `Pillow` | Gestione immagini (copertine) |
|
||||
|
||||
## Avvio
|
||||
|
||||
```bash
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
Dipendenze principali:
|
||||
|
||||
| Pacchetto | Utilizzo |
|
||||
|---|---|
|
||||
| `pywebview` | Finestra nativa con WebView (WebKit / EdgeChromium) |
|
||||
| `requests` | Chiamate API Spotify e GitHub |
|
||||
| `yt-dlp` | Download audio/video da YouTube e oltre 1000 siti |
|
||||
| `mutagen` | Lettura/scrittura tag ID3, MP4, FLAC, WAV |
|
||||
| `pyobjc-framework-WebKit` (macOS) | Backend WebKit di pywebview |
|
||||
| `pythonnet` (Windows) | Backend EdgeChromium di pywebview |
|
||||
|
||||
## Credenziali Spotify API
|
||||
|
||||
1. Vai su https://developer.spotify.com/dashboard e accedi (anche account gratuito)
|
||||
Necessarie solo per scaricare da URL `open.spotify.com/...`.
|
||||
|
||||
1. Vai su <https://developer.spotify.com/dashboard> e accedi (anche account gratuito)
|
||||
2. Clicca **Create app**
|
||||
3. Compila: nome a piacere, Redirect URI `http://localhost:8888/callback`, seleziona **Web API**
|
||||
4. Nella pagina dell'app, clicca **Settings**
|
||||
4. Apri la app e clicca **Settings**
|
||||
5. Copia **Client ID** e **Client Secret** nelle Impostazioni dell'app
|
||||
|
||||
Le credenziali sono gratuite e non richiedono Spotify Premium.
|
||||
Le credenziali sono gratuite, non servono Spotify Premium.
|
||||
|
||||
## Cookies per contenuti privati
|
||||
|
||||
Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTube serve un file `cookies.txt` (formato Netscape). Esportalo dal browser con un'estensione tipo "Get cookies.txt LOCALLY" e imposta il percorso in **Impostazioni → Percorsi**.
|
||||
|
||||
## Guida rapida: registrazione audio di sistema (macOS)
|
||||
|
||||
macOS non espone nativamente l'output audio del sistema. Per registrare quello che esce dagli speaker (es. uno stream Spotify/YouTube), serve un driver virtuale di **loopback**. Il più diffuso è **BlackHole**, gratuito e open source.
|
||||
|
||||
### Installazione
|
||||
|
||||
```bash
|
||||
brew install blackhole-2ch
|
||||
```
|
||||
|
||||
### Configurazione
|
||||
|
||||
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**
|
||||
|
||||
### Vuoi sentire ancora l'audio mentre registri?
|
||||
|
||||
Crea un **Multi-Output Device** in **Audio MIDI Setup**:
|
||||
|
||||
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
|
||||
|
||||
Così l'audio va contemporaneamente agli speaker (lo senti) e a BlackHole (viene registrato).
|
||||
|
||||
## Struttura progetto
|
||||
|
||||
```
|
||||
MusicDownload/
|
||||
├── main.py # Entry point
|
||||
├── requirements.txt # Dipendenze Python
|
||||
├── main.py # Entry point — bootstrap pywebview
|
||||
├── requirements.txt
|
||||
├── build_macos.py # Script build macOS (.app)
|
||||
├── build_windows.py # Script build Windows (.exe)
|
||||
├── .github/workflows/
|
||||
│ └── build.yml # CI: build macOS + Windows + Release automatica su tag
|
||||
│
|
||||
├── core/ # Logica backend
|
||||
├── core/ # Logica backend (riusabile, no UI)
|
||||
│ ├── config.py # Configurazione persistente (JSON)
|
||||
│ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg)
|
||||
│ ├── spotify_client.py # Autenticazione e API Spotify
|
||||
│ ├── downloader.py # Download brani (Spotify via YouTube + URL diretti yt-dlp)
|
||||
│ └── upgrader.py # Upgrade qualita file esistenti
|
||||
│ ├── spotify_client.py # API Spotify (Client Credentials)
|
||||
│ ├── downloader.py # Download audio + video (Spotify, yt-dlp diretto)
|
||||
│ ├── upgrader.py # Upgrade qualità file esistenti
|
||||
│ ├── metadata.py # Lettura/scrittura tag ID3/MP4/Vorbis/WAV
|
||||
│ └── recorder.py # Registrazione audio via ffmpeg
|
||||
│
|
||||
└── gui/ # Interfaccia grafica
|
||||
├── app.py # Finestra principale con sidebar
|
||||
├── download_tab.py # Pagina download
|
||||
├── upgrade_tab.py # Pagina upgrade
|
||||
└── settings_tab.py # Pagina impostazioni
|
||||
├── api/
|
||||
│ └── bridge.py # Ponte JS <-> Python (pywebview.js_api)
|
||||
│
|
||||
└── webui/ # Frontend
|
||||
├── index.html # Single-page, 6 view
|
||||
├── css/style.css # Tema scuro Spotify-like, gradient hero, animazioni
|
||||
└── js/app.js # Routing, eventi, chiamate API
|
||||
```
|
||||
|
||||
## Configurazione
|
||||
|
||||
Il file `config.json` viene creato automaticamente:
|
||||
Il file `config.json` viene creato automaticamente al primo avvio:
|
||||
|
||||
| Chiave | Default | Descrizione |
|
||||
|---|---|---|
|
||||
| `client_id` | `""` | Spotify Client ID |
|
||||
| `client_secret` | `""` | Spotify Client Secret |
|
||||
| `bitrate` | `"320K"` | Bitrate download (128K/192K/256K/320K) |
|
||||
| `bitrate` | `"320K"` | Bitrate download audio (128K/192K/256K/320K) |
|
||||
| `hq_threshold` | `310` | Soglia kbps per considerare un file HQ |
|
||||
| `cookies_path` | `"cookies.txt"` | Path al file cookies YouTube (opzionale) |
|
||||
| `output_dir` | `"MUSICA/"` | Cartella output predefinita |
|
||||
| `theme` | `"dark"` | Tema interfaccia (dark/light/system) |
|
||||
| `cookies_path` | `""` | Path al file `cookies.txt` (opzionale) |
|
||||
| `output_dir` | `""` | Cartella output predefinita |
|
||||
| `theme` | `"dark"` | Tema interfaccia |
|
||||
|
||||
Posizione del file:
|
||||
Posizione:
|
||||
- **Sviluppo**: directory del progetto
|
||||
- **macOS bundle**: `~/Library/Application Support/MusicDownload/`
|
||||
- **Windows bundle**: `%APPDATA%/MusicDownload/`
|
||||
|
||||
## Build
|
||||
## Build locale
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -102,7 +157,7 @@ pip install pyinstaller
|
||||
python3 build_macos.py
|
||||
```
|
||||
|
||||
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg e ffprobe inclusi.
|
||||
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -111,21 +166,36 @@ pip install pyinstaller
|
||||
python build_windows.py
|
||||
```
|
||||
|
||||
Produce `dist/MusicDownload/MusicDownload.exe` con tutti i binari inclusi.
|
||||
Produce `dist/MusicDownload/MusicDownload.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`
|
||||
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
|
||||
|
||||
Per pubblicare una nuova versione:
|
||||
|
||||
```bash
|
||||
git tag v1.3.0
|
||||
git push origin v1.3.0
|
||||
```
|
||||
|
||||
Per fare solo una build di test (senza release):
|
||||
|
||||
```bash
|
||||
gh workflow run "Build MusicDownload" --ref main
|
||||
```
|
||||
|
||||
oppure dalla UI: **Actions → Build MusicDownload → Run workflow**.
|
||||
|
||||
Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>.
|
||||
|
||||
## Aggiornamento app
|
||||
|
||||
L'app verifica la disponibilita di nuove versioni tramite un file `version.json` remoto. Il formato atteso:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v1.1",
|
||||
"download_url": "https://www.djluza.com/musicdownload/MusicDownload.dmg",
|
||||
"notes": "Descrizione delle novita"
|
||||
}
|
||||
```
|
||||
|
||||
L'URL di controllo e configurabile nella costante `UPDATE_URL` in `gui/settings_tab.py`.
|
||||
L'app verifica nuove versioni interrogando l'API GitHub Releases (`api/bridge.py` → `check_update`). Per funzionare end-to-end serve che il repo sia pubblico, altrimenti l'API restituisce 404.
|
||||
|
||||
## Licenza
|
||||
|
||||
|
||||
+244
-11
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
@@ -19,6 +20,13 @@ from core.downloader import (
|
||||
is_stopped as download_is_stopped,
|
||||
)
|
||||
from core.spotify_client import get_access_token, resolve_spotify_url
|
||||
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||
from core.recorder import (
|
||||
list_input_devices,
|
||||
start_recording,
|
||||
stop_recording as recorder_stop,
|
||||
is_recording,
|
||||
)
|
||||
from core.upgrader import (
|
||||
upgrade_folder,
|
||||
request_stop as request_upgrade_stop,
|
||||
@@ -49,6 +57,30 @@ def _js_safe(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
_NUM_PREFIX = re.compile(r"^\s*\d+\s*[\.\)\:\-]\s*")
|
||||
_DURATION_TAIL = re.compile(r"\s*\(\s*\d{1,2}:\d{2}\s*\)\s*$")
|
||||
# Tutti i tipi di trattini: hyphen, en-dash, em-dash, horizontal bar, minus
|
||||
_DASH_SPLIT = re.compile(r"\s+[‐‑‒–—―−\-]\s+")
|
||||
|
||||
|
||||
def _parse_track_line(line: str) -> Optional[dict]:
|
||||
"""Parse 'N. Artista - Titolo (variant) (durata)' -> {name, artist}.
|
||||
Ritorna None se la riga non e parseabile."""
|
||||
s = line.strip()
|
||||
if not s:
|
||||
return None
|
||||
s = _NUM_PREFIX.sub("", s)
|
||||
s = _DURATION_TAIL.sub("", s).strip()
|
||||
parts = _DASH_SPLIT.split(s, maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
artist = parts[0].strip()
|
||||
name = parts[1].strip()
|
||||
if not artist or not name:
|
||||
return None
|
||||
return {"name": name, "artist": artist}
|
||||
|
||||
|
||||
class Api:
|
||||
"""Espone i metodi a JavaScript tramite pywebview js_api."""
|
||||
|
||||
@@ -109,22 +141,43 @@ class Api:
|
||||
return {"ok": True}
|
||||
|
||||
def check_update(self) -> dict:
|
||||
"""Controlla aggiornamenti chiamando version.json remoto."""
|
||||
import urllib.request
|
||||
"""Controlla aggiornamenti interrogando l'API GitHub Releases."""
|
||||
import platform
|
||||
import requests
|
||||
|
||||
UPDATE_URL = "https://www.djluza.com/musicdownload/version.json"
|
||||
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
|
||||
try:
|
||||
req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "MusicDownload"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
remote = data.get("version", "")
|
||||
resp = requests.get(API_URL, headers={
|
||||
"User-Agent": "MusicDownload",
|
||||
"Accept": "application/vnd.github+json",
|
||||
}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
remote = data.get("tag_name", "")
|
||||
notes = data.get("body", "") or ""
|
||||
html_url = data.get("html_url", "")
|
||||
|
||||
# Trova l'asset giusto per la piattaforma
|
||||
assets = data.get("assets", []) or []
|
||||
is_macos = platform.system() == "Darwin"
|
||||
keyword = "macos" if is_macos else "windows"
|
||||
download_url = ""
|
||||
for a in assets:
|
||||
name = (a.get("name") or "").lower()
|
||||
if keyword in name and name.endswith(".zip"):
|
||||
download_url = a.get("browser_download_url", "")
|
||||
break
|
||||
if not download_url:
|
||||
download_url = html_url # fallback: pagina release
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"current": VERSION,
|
||||
"remote": remote,
|
||||
"is_new": bool(remote and remote != VERSION),
|
||||
"download_url": data.get("download_url", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
"download_url": download_url,
|
||||
"notes": notes[:500] if notes else "", # tronca per UI
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "current": VERSION}
|
||||
@@ -167,16 +220,115 @@ class Api:
|
||||
return ""
|
||||
|
||||
def load_url_list(self, path: str) -> dict:
|
||||
"""Carica un file .txt. Riconosce automaticamente se contiene URL
|
||||
oppure una tracklist 'Artista - Titolo'."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
urls = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
||||
return {"ok": True, "urls": urls, "count": len(urls)}
|
||||
lines = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
||||
if not lines:
|
||||
return {"ok": True, "kind": "urls", "urls": [], "count": 0}
|
||||
|
||||
# Detect: se la prima riga e un URL trattiamo tutto come URL list
|
||||
if lines[0].lower().startswith(("http://", "https://")):
|
||||
return {"ok": True, "kind": "urls", "urls": lines, "count": len(lines)}
|
||||
|
||||
# Altrimenti parse come tracklist
|
||||
tracks = []
|
||||
for line in lines:
|
||||
t = _parse_track_line(line)
|
||||
if t:
|
||||
tracks.append(t)
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
return {
|
||||
"ok": True, "kind": "tracks", "tracks": tracks,
|
||||
"count": len(tracks), "name": name,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def open_external_url(self, url: str) -> None:
|
||||
webbrowser.open(url)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Metadata editor
|
||||
# ------------------------------------------------------------------
|
||||
def pick_audio_file(self) -> str:
|
||||
return self.browse_file([
|
||||
"Audio (*.mp3;*.m4a;*.mp4;*.aac;*.flac;*.wav)",
|
||||
"All files (*.*)",
|
||||
])
|
||||
|
||||
def pick_image_file(self) -> str:
|
||||
return self.browse_file([
|
||||
"Immagini (*.jpg;*.jpeg;*.png)",
|
||||
"All files (*.*)",
|
||||
])
|
||||
|
||||
def read_metadata(self, path: str) -> dict:
|
||||
try:
|
||||
data = read_metadata(path)
|
||||
return {"ok": True, "data": data}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def save_metadata(self, payload: dict) -> dict:
|
||||
path = (payload.get("path") or "").strip()
|
||||
if not path:
|
||||
return {"ok": False, "error": "Path mancante"}
|
||||
if not os.path.exists(path):
|
||||
return {"ok": False, "error": "File non trovato"}
|
||||
|
||||
data = payload.get("data") or {}
|
||||
cover_path = (payload.get("cover_path") or "").strip() or None
|
||||
remove_cover = bool(payload.get("remove_cover", False))
|
||||
|
||||
try:
|
||||
write_metadata(path, data, cover_path, remove_cover)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recorder
|
||||
# ------------------------------------------------------------------
|
||||
def list_audio_inputs(self) -> dict:
|
||||
try:
|
||||
return {"ok": True, "devices": list_input_devices()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "devices": []}
|
||||
|
||||
def start_audio_recording(self, payload: dict) -> dict:
|
||||
device_id = str(payload.get("device_id", "")).strip()
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
filename = (payload.get("filename") or "").strip()
|
||||
bitrate = (payload.get("bitrate") or "320k").strip()
|
||||
|
||||
if not device_id:
|
||||
return {"ok": False, "error": "Dispositivo non selezionato"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
if not filename:
|
||||
filename = f"Registrazione_{time.strftime('%Y%m%d_%H%M%S')}.mp3"
|
||||
if not filename.lower().endswith(".mp3"):
|
||||
filename += ".mp3"
|
||||
|
||||
out_path = os.path.join(output_dir, filename)
|
||||
|
||||
def cb(status, payload_evt):
|
||||
payload_evt = dict(payload_evt or {})
|
||||
payload_evt["status"] = status
|
||||
self._emit("recording:event", payload_evt)
|
||||
|
||||
res = start_recording(device_id, out_path, bitrate=bitrate,
|
||||
progress_callback=cb)
|
||||
return res
|
||||
|
||||
def stop_audio_recording(self) -> dict:
|
||||
return recorder_stop()
|
||||
|
||||
def is_audio_recording(self) -> dict:
|
||||
return {"recording": is_recording()}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Audio folder scan (Upgrade tab)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -215,11 +367,92 @@ class Api:
|
||||
self._download_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def start_tracks_download(self, payload: dict) -> dict:
|
||||
"""Avvia il download di una tracklist gia parsata
|
||||
(lista di {name, artist}). Se 'subfolder' e presente,
|
||||
scarica in output_dir/subfolder."""
|
||||
if self._any_job_running():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
tracks = payload.get("tracks") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
subfolder = (payload.get("subfolder") or "").strip()
|
||||
if not tracks:
|
||||
return {"ok": False, "error": "Nessuna traccia fornita"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
if subfolder:
|
||||
# Sanitizza: rimuovi separatori di path
|
||||
safe = subfolder.replace("/", "_").replace("\\", "_").strip()
|
||||
if safe:
|
||||
output_dir = os.path.join(output_dir, safe)
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._tracks_worker,
|
||||
args=(list(tracks), output_dir),
|
||||
daemon=True,
|
||||
)
|
||||
self._download_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def stop_download(self) -> dict:
|
||||
request_download_stop()
|
||||
self._log("download", "[INFO] Interruzione richiesta...")
|
||||
return {"ok": True}
|
||||
|
||||
def _tracks_worker(self, tracks: list, output_dir: str) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
view = "download"
|
||||
|
||||
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
|
||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.10
|
||||
|
||||
def progress_cb(idx, total, track_name, status, pct):
|
||||
if status == "downloading":
|
||||
now = time.monotonic()
|
||||
if now - _last[0] < _THROTTLE:
|
||||
return
|
||||
_last[0] = now
|
||||
|
||||
payload_evt = {
|
||||
"idx": idx, "total": total, "track": track_name,
|
||||
"status": status, "pct": pct,
|
||||
"url_idx": 0, "url_total": 1,
|
||||
}
|
||||
|
||||
if status == "searching":
|
||||
self._log(view, f"[CERCA] {track_name}")
|
||||
elif status == "skipped":
|
||||
self._log(view, f"[SKIP] {track_name} (gia scaricato)")
|
||||
track_progress = (idx + 1) / total if total > 0 else 1
|
||||
payload_evt["overall"] = min(track_progress, 1.0)
|
||||
elif status == "downloading":
|
||||
track_progress = (idx / total) + (pct / 100 / total) if total > 0 else 0
|
||||
payload_evt["overall"] = min(track_progress, 1.0)
|
||||
elif status == "done":
|
||||
self._log(view, f"[OK] {track_name}")
|
||||
track_progress = (idx + 1) / total if total > 0 else 1
|
||||
payload_evt["overall"] = min(track_progress, 1.0)
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Download interrotto.")
|
||||
elif status == "completed":
|
||||
payload_evt["overall"] = 1.0
|
||||
self._log(view, "[INFO] Download completato!")
|
||||
elif status.startswith("error"):
|
||||
self._log(view, f"[ERRORE] {track_name}: {status}")
|
||||
|
||||
self._emit("download:progress", payload_evt)
|
||||
|
||||
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb)
|
||||
self._emit("download:done", {"ok": True})
|
||||
|
||||
def _download_worker(self, urls: list, output_dir: str) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
|
||||
@@ -220,6 +220,7 @@ def run_pyinstaller():
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.cocoa",
|
||||
"--hidden-import", "requests",
|
||||
"--collect-all", "mutagen",
|
||||
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
|
||||
@@ -116,6 +116,7 @@ def run_pyinstaller():
|
||||
"--hidden-import", "requests",
|
||||
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
|
||||
"--collect-all", "webview",
|
||||
"--collect-all", "mutagen",
|
||||
# Binari bundled
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.0"
|
||||
VERSION = "v1.2.2"
|
||||
|
||||
|
||||
def _get_config_dir() -> Path:
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Lettura e scrittura metadati audio (ID3 per MP3, MP4 per M4A, Vorbis per FLAC)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import plistlib
|
||||
import subprocess
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import (
|
||||
ID3, ID3NoHeaderError,
|
||||
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
|
||||
)
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.wave import WAVE
|
||||
|
||||
|
||||
SUPPORTED_EXTS = (".mp3", ".m4a", ".mp4", ".aac", ".flac", ".wav")
|
||||
|
||||
_WHERE_FROM_ATTR = "com.apple.metadata:kMDItemWhereFroms"
|
||||
|
||||
|
||||
def _read_where_from(path: str) -> list:
|
||||
"""Legge l'xattr macOS 'kMDItemWhereFroms' (lista di URL/origini).
|
||||
Ritorna [] se non e macOS o se l'attributo non esiste."""
|
||||
if sys.platform != "darwin":
|
||||
return []
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["xattr", "-px", _WHERE_FROM_ATTR, path],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
hex_str = "".join(res.stdout.split())
|
||||
if not hex_str:
|
||||
return []
|
||||
raw = bytes.fromhex(hex_str)
|
||||
items = plistlib.loads(raw)
|
||||
if isinstance(items, list):
|
||||
return [str(x) for x in items]
|
||||
return [str(items)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _write_where_from(path: str, urls: list) -> None:
|
||||
"""Scrive l'xattr macOS 'kMDItemWhereFroms'. Lista vuota -> rimuove."""
|
||||
if sys.platform != "darwin":
|
||||
return
|
||||
try:
|
||||
clean = [u.strip() for u in (urls or []) if u and u.strip()]
|
||||
if not clean:
|
||||
subprocess.run(
|
||||
["xattr", "-d", _WHERE_FROM_ATTR, path],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return
|
||||
data = plistlib.dumps(clean, fmt=plistlib.FMT_BINARY)
|
||||
subprocess.run(
|
||||
["xattr", "-wx", _WHERE_FROM_ATTR, data.hex(), path],
|
||||
capture_output=True, timeout=5, check=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
"""Estrai testo da un frame mutagen."""
|
||||
if value is None:
|
||||
return ""
|
||||
if hasattr(value, "text"):
|
||||
t = value.text
|
||||
if isinstance(t, list) and t:
|
||||
return str(t[0])
|
||||
return str(t)
|
||||
if isinstance(value, list) and value:
|
||||
return str(value[0])
|
||||
return str(value)
|
||||
|
||||
|
||||
def _read_mp3(path: str, result: dict) -> dict:
|
||||
result["format"] = "MP3"
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
return result
|
||||
|
||||
result["title"] = _text(tags.get("TIT2"))
|
||||
result["artist"] = _text(tags.get("TPE1"))
|
||||
result["album_artist"] = _text(tags.get("TPE2"))
|
||||
result["album"] = _text(tags.get("TALB"))
|
||||
result["year"] = _text(tags.get("TDRC"))
|
||||
result["track"] = _text(tags.get("TRCK"))
|
||||
result["genre"] = _text(tags.get("TCON"))
|
||||
result["bpm"] = _text(tags.get("TBPM"))
|
||||
result["key"] = _text(tags.get("TKEY"))
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("COMM"):
|
||||
result["comment"] = _text(tags[k])
|
||||
break
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("APIC"):
|
||||
apic = tags[k]
|
||||
result["cover_base64"] = base64.b64encode(apic.data).decode("ascii")
|
||||
result["cover_mime"] = apic.mime or "image/jpeg"
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _read_mp4(path: str, result: dict) -> dict:
|
||||
result["format"] = "M4A/MP4"
|
||||
m = MP4(path)
|
||||
tags = m.tags or {}
|
||||
result["title"] = _text(tags.get("\xa9nam"))
|
||||
result["artist"] = _text(tags.get("\xa9ART"))
|
||||
result["album_artist"] = _text(tags.get("aART"))
|
||||
result["album"] = _text(tags.get("\xa9alb"))
|
||||
result["year"] = _text(tags.get("\xa9day"))
|
||||
track_pair = tags.get("trkn")
|
||||
if track_pair and isinstance(track_pair, list) and track_pair:
|
||||
num, tot = track_pair[0] if isinstance(track_pair[0], tuple) else (track_pair[0], 0)
|
||||
result["track"] = f"{num}/{tot}" if tot else str(num)
|
||||
result["genre"] = _text(tags.get("\xa9gen"))
|
||||
result["comment"] = _text(tags.get("\xa9cmt"))
|
||||
result["bpm"] = _text(tags.get("tmpo"))
|
||||
|
||||
covers = tags.get("covr")
|
||||
if covers:
|
||||
c = covers[0]
|
||||
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
|
||||
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png"
|
||||
return result
|
||||
|
||||
|
||||
_WAV_INFO_MAP = {
|
||||
"INAM": "title",
|
||||
"IART": "artist",
|
||||
"IPRD": "album",
|
||||
"ICRD": "year",
|
||||
"IYER": "year",
|
||||
"IGNR": "genre",
|
||||
"ICMT": "comment",
|
||||
"ITRK": "track",
|
||||
"IPRT": "track",
|
||||
"TRCK": "track",
|
||||
"IBPM": "bpm",
|
||||
"TBPM": "bpm",
|
||||
"TKEY": "key",
|
||||
}
|
||||
|
||||
|
||||
def _read_wav_info_chunk(path: str) -> dict:
|
||||
"""Legge il chunk RIFF LIST/INFO di un file WAV.
|
||||
Ritorna un dict con i campi noti. Usato come fallback quando
|
||||
il WAV non contiene ID3 embedded."""
|
||||
out: dict = {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(12)
|
||||
if len(header) < 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE":
|
||||
return out
|
||||
while True:
|
||||
hdr = f.read(8)
|
||||
if len(hdr) < 8:
|
||||
break
|
||||
chunk_id, chunk_size = struct.unpack("<4sI", hdr)
|
||||
if chunk_id == b"LIST":
|
||||
list_type = f.read(4)
|
||||
list_end = f.tell() + (chunk_size - 4)
|
||||
if list_type == b"INFO":
|
||||
while f.tell() < list_end:
|
||||
sub = f.read(8)
|
||||
if len(sub) < 8:
|
||||
break
|
||||
sub_id, sub_size = struct.unpack("<4sI", sub)
|
||||
data = f.read(sub_size).rstrip(b"\x00")
|
||||
if sub_size % 2 == 1:
|
||||
f.read(1) # padding word-aligned
|
||||
key = _WAV_INFO_MAP.get(sub_id.decode("ascii", errors="ignore"))
|
||||
if not key or not data:
|
||||
continue
|
||||
for enc in ("utf-8", "latin-1"):
|
||||
try:
|
||||
out[key] = data.decode(enc).strip()
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
else:
|
||||
f.seek(list_end)
|
||||
else:
|
||||
skip = chunk_size + (chunk_size % 2)
|
||||
f.seek(skip, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _read_wav(path: str, result: dict) -> dict:
|
||||
result["format"] = "WAV"
|
||||
|
||||
# Prima fallback: leggi il chunk INFO (formato RIFF originale)
|
||||
info = _read_wav_info_chunk(path)
|
||||
for k, v in info.items():
|
||||
if v:
|
||||
result[k] = v
|
||||
|
||||
# Poi leggi ID3 se presente; ha priorita sui valori INFO
|
||||
w = WAVE(path)
|
||||
tags = w.tags
|
||||
if tags is None:
|
||||
return result
|
||||
|
||||
def setif(key, frame_key):
|
||||
v = _text(tags.get(frame_key))
|
||||
if v:
|
||||
result[key] = v
|
||||
|
||||
setif("title", "TIT2")
|
||||
setif("artist", "TPE1")
|
||||
setif("album_artist", "TPE2")
|
||||
setif("album", "TALB")
|
||||
setif("year", "TDRC")
|
||||
setif("track", "TRCK")
|
||||
setif("genre", "TCON")
|
||||
setif("bpm", "TBPM")
|
||||
setif("key", "TKEY")
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("COMM"):
|
||||
v = _text(tags[k])
|
||||
if v:
|
||||
result["comment"] = v
|
||||
break
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("APIC"):
|
||||
apic = tags[k]
|
||||
result["cover_base64"] = base64.b64encode(apic.data).decode("ascii")
|
||||
result["cover_mime"] = apic.mime or "image/jpeg"
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _read_flac(path: str, result: dict) -> dict:
|
||||
result["format"] = "FLAC"
|
||||
f = FLAC(path)
|
||||
g = lambda k: (f.get(k) or [""])[0] if f.get(k) else ""
|
||||
result["title"] = g("title")
|
||||
result["artist"] = g("artist")
|
||||
result["album_artist"] = g("albumartist")
|
||||
result["album"] = g("album")
|
||||
result["year"] = g("date")
|
||||
result["track"] = g("tracknumber")
|
||||
result["genre"] = g("genre")
|
||||
result["bpm"] = g("bpm")
|
||||
result["key"] = g("initialkey")
|
||||
result["comment"] = g("comment")
|
||||
if f.pictures:
|
||||
pic = f.pictures[0]
|
||||
result["cover_base64"] = base64.b64encode(pic.data).decode("ascii")
|
||||
result["cover_mime"] = pic.mime or "image/jpeg"
|
||||
return result
|
||||
|
||||
|
||||
def read_metadata(path: str) -> dict:
|
||||
"""Ritorna un dict con tutti i metadati leggibili dal file."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
ext = p.suffix.lower()
|
||||
if ext not in SUPPORTED_EXTS:
|
||||
raise ValueError(f"Formato non supportato: {ext}")
|
||||
|
||||
result = {
|
||||
"path": str(p),
|
||||
"filename": p.name,
|
||||
"format": "",
|
||||
"title": "", "artist": "", "album_artist": "",
|
||||
"album": "", "year": "", "track": "",
|
||||
"genre": "", "comment": "",
|
||||
"bpm": "", "key": "",
|
||||
"duration": 0, "bitrate": 0,
|
||||
"cover_base64": "", "cover_mime": "",
|
||||
"where_from": _read_where_from(str(p)),
|
||||
"is_macos": sys.platform == "darwin",
|
||||
}
|
||||
|
||||
audio = MutagenFile(str(p))
|
||||
if audio is None:
|
||||
raise ValueError(f"Impossibile leggere: {ext}")
|
||||
|
||||
info = getattr(audio, "info", None)
|
||||
if info:
|
||||
result["duration"] = int(getattr(info, "length", 0) or 0)
|
||||
result["bitrate"] = int((getattr(info, "bitrate", 0) or 0) // 1000)
|
||||
|
||||
if ext == ".mp3":
|
||||
return _read_mp3(str(p), result)
|
||||
if ext in (".m4a", ".mp4", ".aac"):
|
||||
return _read_mp4(str(p), result)
|
||||
if ext == ".flac":
|
||||
return _read_flac(str(p), result)
|
||||
if ext == ".wav":
|
||||
return _read_wav(str(p), result)
|
||||
return result
|
||||
|
||||
|
||||
def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
tags = ID3()
|
||||
|
||||
def set_or_del(key, frame_cls, value):
|
||||
if value:
|
||||
tags[key] = frame_cls(encoding=3, text=value)
|
||||
elif key in tags:
|
||||
del tags[key]
|
||||
|
||||
set_or_del("TIT2", TIT2, data.get("title", ""))
|
||||
set_or_del("TPE1", TPE1, data.get("artist", ""))
|
||||
set_or_del("TPE2", TPE2, data.get("album_artist", ""))
|
||||
set_or_del("TALB", TALB, data.get("album", ""))
|
||||
set_or_del("TDRC", TDRC, data.get("year", ""))
|
||||
set_or_del("TRCK", TRCK, data.get("track", ""))
|
||||
set_or_del("TCON", TCON, data.get("genre", ""))
|
||||
set_or_del("TBPM", TBPM, data.get("bpm", ""))
|
||||
set_or_del("TKEY", TKEY, data.get("key", ""))
|
||||
|
||||
# Rimuovi tutti i COMM esistenti
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("COMM"):
|
||||
del tags[k]
|
||||
comment = data.get("comment", "")
|
||||
if comment:
|
||||
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
|
||||
|
||||
# Cover
|
||||
if remove_cover or cover_path:
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("APIC"):
|
||||
del tags[k]
|
||||
if cover_path:
|
||||
cp = Path(cover_path)
|
||||
if cp.exists():
|
||||
ext = cp.suffix.lower()
|
||||
mime = "image/png" if ext == ".png" else "image/jpeg"
|
||||
with open(cp, "rb") as f:
|
||||
cover_data = f.read()
|
||||
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data))
|
||||
|
||||
tags.save(path, v2_version=3)
|
||||
|
||||
|
||||
def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
|
||||
m = MP4(path)
|
||||
|
||||
def set_or_del(key, value):
|
||||
if value:
|
||||
m[key] = value
|
||||
elif key in m:
|
||||
del m[key]
|
||||
|
||||
set_or_del("\xa9nam", data.get("title", ""))
|
||||
set_or_del("\xa9ART", data.get("artist", ""))
|
||||
set_or_del("aART", data.get("album_artist", ""))
|
||||
set_or_del("\xa9alb", data.get("album", ""))
|
||||
set_or_del("\xa9day", data.get("year", ""))
|
||||
set_or_del("\xa9gen", data.get("genre", ""))
|
||||
set_or_del("\xa9cmt", data.get("comment", ""))
|
||||
|
||||
track = data.get("track", "")
|
||||
if track:
|
||||
try:
|
||||
parts = track.split("/")
|
||||
num = int(parts[0])
|
||||
tot = int(parts[1]) if len(parts) > 1 else 0
|
||||
m["trkn"] = [(num, tot)]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif "trkn" in m:
|
||||
del m["trkn"]
|
||||
|
||||
bpm = data.get("bpm", "")
|
||||
if bpm:
|
||||
try:
|
||||
m["tmpo"] = [int(bpm)]
|
||||
except ValueError:
|
||||
pass
|
||||
elif "tmpo" in m:
|
||||
del m["tmpo"]
|
||||
|
||||
if remove_cover or cover_path:
|
||||
if "covr" in m:
|
||||
del m["covr"]
|
||||
if cover_path:
|
||||
cp = Path(cover_path)
|
||||
if cp.exists():
|
||||
ext = cp.suffix.lower()
|
||||
fmt = MP4Cover.FORMAT_PNG if ext == ".png" else MP4Cover.FORMAT_JPEG
|
||||
with open(cp, "rb") as f:
|
||||
cover_data = f.read()
|
||||
m["covr"] = [MP4Cover(cover_data, imageformat=fmt)]
|
||||
|
||||
m.save()
|
||||
|
||||
|
||||
def _write_flac(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
|
||||
f = FLAC(path)
|
||||
|
||||
def set_or_del(key, value):
|
||||
if value:
|
||||
f[key] = value
|
||||
elif key in f:
|
||||
del f[key]
|
||||
|
||||
set_or_del("title", data.get("title", ""))
|
||||
set_or_del("artist", data.get("artist", ""))
|
||||
set_or_del("albumartist", data.get("album_artist", ""))
|
||||
set_or_del("album", data.get("album", ""))
|
||||
set_or_del("date", data.get("year", ""))
|
||||
set_or_del("tracknumber", data.get("track", ""))
|
||||
set_or_del("genre", data.get("genre", ""))
|
||||
set_or_del("bpm", data.get("bpm", ""))
|
||||
set_or_del("initialkey", data.get("key", ""))
|
||||
set_or_del("comment", data.get("comment", ""))
|
||||
|
||||
if remove_cover or cover_path:
|
||||
f.clear_pictures()
|
||||
if cover_path:
|
||||
cp = Path(cover_path)
|
||||
if cp.exists():
|
||||
ext = cp.suffix.lower()
|
||||
pic = Picture()
|
||||
with open(cp, "rb") as fh:
|
||||
pic.data = fh.read()
|
||||
pic.type = 3
|
||||
pic.mime = "image/png" if ext == ".png" else "image/jpeg"
|
||||
f.add_picture(pic)
|
||||
|
||||
f.save()
|
||||
|
||||
|
||||
def _write_wav(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
|
||||
w = WAVE(path)
|
||||
if w.tags is None:
|
||||
w.add_tags()
|
||||
tags = w.tags
|
||||
|
||||
def set_or_del(key, frame_cls, value):
|
||||
if value:
|
||||
tags[key] = frame_cls(encoding=3, text=value)
|
||||
elif key in tags:
|
||||
del tags[key]
|
||||
|
||||
set_or_del("TIT2", TIT2, data.get("title", ""))
|
||||
set_or_del("TPE1", TPE1, data.get("artist", ""))
|
||||
set_or_del("TPE2", TPE2, data.get("album_artist", ""))
|
||||
set_or_del("TALB", TALB, data.get("album", ""))
|
||||
set_or_del("TDRC", TDRC, data.get("year", ""))
|
||||
set_or_del("TRCK", TRCK, data.get("track", ""))
|
||||
set_or_del("TCON", TCON, data.get("genre", ""))
|
||||
set_or_del("TBPM", TBPM, data.get("bpm", ""))
|
||||
set_or_del("TKEY", TKEY, data.get("key", ""))
|
||||
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("COMM"):
|
||||
del tags[k]
|
||||
comment = data.get("comment", "")
|
||||
if comment:
|
||||
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
|
||||
|
||||
if remove_cover or cover_path:
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("APIC"):
|
||||
del tags[k]
|
||||
if cover_path:
|
||||
cp = Path(cover_path)
|
||||
if cp.exists():
|
||||
ext_c = cp.suffix.lower()
|
||||
mime = "image/png" if ext_c == ".png" else "image/jpeg"
|
||||
with open(cp, "rb") as f:
|
||||
cover_data = f.read()
|
||||
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data))
|
||||
|
||||
w.save()
|
||||
|
||||
|
||||
def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
|
||||
remove_cover: bool = False) -> None:
|
||||
"""Salva i metadati. Se remove_cover=True rimuove la copertina esistente
|
||||
senza sostituirla. Se cover_path e fornito, la sostituisce.
|
||||
Su macOS aggiorna anche l'xattr kMDItemWhereFroms se data['where_from']
|
||||
e fornito (lista di stringhe)."""
|
||||
ext = Path(path).suffix.lower()
|
||||
if ext == ".mp3":
|
||||
_write_mp3(path, data, cover_path, remove_cover)
|
||||
elif ext in (".m4a", ".mp4", ".aac"):
|
||||
_write_mp4(path, data, cover_path, remove_cover)
|
||||
elif ext == ".flac":
|
||||
_write_flac(path, data, cover_path, remove_cover)
|
||||
elif ext == ".wav":
|
||||
_write_wav(path, data, cover_path, remove_cover)
|
||||
else:
|
||||
raise ValueError(f"Formato non supportato: {ext}")
|
||||
|
||||
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
|
||||
if "where_from" in data:
|
||||
_write_where_from(path, data.get("where_from") or [])
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Registrazione audio da un dispositivo di input via ffmpeg.
|
||||
|
||||
macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole)
|
||||
Windows: usa DirectShow
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ffmpeg
|
||||
|
||||
|
||||
_IS_MAC = sys.platform == "darwin"
|
||||
_IS_WIN = sys.platform == "win32"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Stato globale (un solo recording alla volta)
|
||||
# ============================================================
|
||||
_current_proc: Optional[subprocess.Popen] = None
|
||||
_proc_lock = threading.Lock()
|
||||
_stop_event = threading.Event()
|
||||
_recording_thread: Optional[threading.Thread] = None
|
||||
|
||||
|
||||
def is_recording() -> bool:
|
||||
with _proc_lock:
|
||||
return _current_proc is not None and _current_proc.poll() is None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}.
|
||||
|
||||
is_virtual = True per driver di loopback noti (BlackHole, Soundflower,
|
||||
Loopback, Audio Hijack, VB-Cable, ...)
|
||||
"""
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return []
|
||||
|
||||
if _IS_MAC:
|
||||
return _list_macos(ffmpeg)
|
||||
if _IS_WIN:
|
||||
return _list_windows(ffmpeg)
|
||||
return []
|
||||
|
||||
|
||||
_VIRTUAL_KEYWORDS = (
|
||||
"blackhole", "soundflower", "loopback", "audio hijack",
|
||||
"vb-cable", "vb-audio", "stereo mix", "what u hear",
|
||||
"obs", "system audio",
|
||||
)
|
||||
|
||||
|
||||
def _is_virtual(name: str) -> bool:
|
||||
n = (name or "").lower()
|
||||
return any(k in n for k in _VIRTUAL_KEYWORDS)
|
||||
|
||||
|
||||
def _list_macos(ffmpeg: str) -> list[dict]:
|
||||
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
|
||||
L'output va su stderr."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
||||
"-list_devices", "true", "-i", ""],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
devices: list[dict] = []
|
||||
in_audio = False
|
||||
audio_section_re = re.compile(r"AVFoundation\s+audio\s+devices", re.IGNORECASE)
|
||||
other_section_re = re.compile(r"AVFoundation\s+(?:video)\s+devices", re.IGNORECASE)
|
||||
line_re = re.compile(r"\[(\d+)\]\s+(.+?)\s*$")
|
||||
|
||||
for raw in proc.stderr.splitlines():
|
||||
line = raw.strip()
|
||||
if audio_section_re.search(line):
|
||||
in_audio = True
|
||||
continue
|
||||
if other_section_re.search(line):
|
||||
in_audio = False
|
||||
continue
|
||||
if not in_audio:
|
||||
continue
|
||||
clean = re.sub(r"^\[AVFoundation[^\]]*\]\s*", "", line)
|
||||
m = line_re.match(clean)
|
||||
if not m:
|
||||
continue
|
||||
idx, name = m.group(1), m.group(2).strip()
|
||||
devices.append({"id": idx, "name": name, "is_virtual": _is_virtual(name)})
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def _list_windows(ffmpeg: str) -> list[dict]:
|
||||
"""Parsa l'output di `ffmpeg -f dshow -list_devices true -i dummy`."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-f", "dshow",
|
||||
"-list_devices", "true", "-i", "dummy"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
devices: list[dict] = []
|
||||
in_audio = False
|
||||
line_re = re.compile(r'"([^"]+)"\s*\((audio|video)\)', re.IGNORECASE)
|
||||
name_re = re.compile(r'"([^"]+)"')
|
||||
|
||||
for raw in proc.stderr.splitlines():
|
||||
line = raw.strip()
|
||||
low = line.lower()
|
||||
if "directshow audio devices" in low:
|
||||
in_audio = True
|
||||
continue
|
||||
if "directshow video devices" in low:
|
||||
in_audio = False
|
||||
continue
|
||||
|
||||
m = line_re.search(line)
|
||||
if m:
|
||||
name, kind = m.group(1), m.group(2).lower()
|
||||
if kind == "audio":
|
||||
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
|
||||
continue
|
||||
|
||||
if in_audio:
|
||||
mn = name_re.search(line)
|
||||
if mn:
|
||||
name = mn.group(1)
|
||||
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
|
||||
|
||||
# Dedup preservando l'ordine
|
||||
seen = set()
|
||||
out = []
|
||||
for d in devices:
|
||||
if d["id"] in seen:
|
||||
continue
|
||||
seen.add(d["id"])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione
|
||||
# ============================================================
|
||||
def _build_record_cmd(ffmpeg: str, device_id: str, output_path: str,
|
||||
bitrate: str) -> list[str]:
|
||||
"""Comando ffmpeg per registrare su file MP3."""
|
||||
if _IS_MAC:
|
||||
return [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "avfoundation",
|
||||
"-i", f":{device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
output_path,
|
||||
]
|
||||
if _IS_WIN:
|
||||
return [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
output_path,
|
||||
]
|
||||
raise RuntimeError("Sistema operativo non supportato per la registrazione")
|
||||
|
||||
|
||||
def start_recording(
|
||||
device_id: str,
|
||||
output_path: str,
|
||||
bitrate: str = "320k",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
"""Avvia una registrazione in un thread.
|
||||
Ritorna {ok, error?, output_path?}.
|
||||
|
||||
progress_callback(status, payload) viene chiamato con:
|
||||
("started", {"output_path": ...})
|
||||
("tick", {"seconds": int})
|
||||
("stopped", {"output_path": ..., "seconds": int})
|
||||
("error", {"error": "..."})
|
||||
"""
|
||||
global _current_proc, _recording_thread
|
||||
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return {"ok": False, "error": "ffmpeg non trovato"}
|
||||
|
||||
out = Path(output_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
|
||||
|
||||
_stop_event.clear()
|
||||
|
||||
def _worker():
|
||||
global _current_proc
|
||||
try:
|
||||
with _proc_lock:
|
||||
_current_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False, # bytes su stdin per "q"
|
||||
)
|
||||
|
||||
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
|
||||
rc = _current_proc.poll()
|
||||
if rc is not None:
|
||||
break
|
||||
elapsed = int(time.monotonic() - start)
|
||||
if progress_callback:
|
||||
progress_callback("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
# Stop gracefully
|
||||
if _current_proc.poll() is None:
|
||||
try:
|
||||
_current_proc.stdin.write(b"q\n")
|
||||
_current_proc.stdin.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_current_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.terminate()
|
||||
try:
|
||||
_current_proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.kill()
|
||||
|
||||
rc = _current_proc.returncode
|
||||
seconds = int(time.monotonic() - start)
|
||||
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
|
||||
if progress_callback:
|
||||
if rc == 0 or out.exists():
|
||||
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
||||
else:
|
||||
err = ""
|
||||
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:
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": str(e)})
|
||||
|
||||
_recording_thread = threading.Thread(target=_worker, daemon=True)
|
||||
_recording_thread.start()
|
||||
|
||||
return {"ok": True, "output_path": str(out)}
|
||||
|
||||
|
||||
def _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 stop_recording() -> dict:
|
||||
"""Ferma la registrazione corrente (se attiva)."""
|
||||
_stop_event.set()
|
||||
return {"ok": True}
|
||||
@@ -1,6 +1,7 @@
|
||||
pywebview>=5.0
|
||||
requests>=2.31.0
|
||||
yt-dlp>=2024.0.0
|
||||
mutagen>=1.47.0
|
||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
--pink-hover: #F472B6;
|
||||
--pink-dim: #831843;
|
||||
|
||||
--amber: #F59E0B;
|
||||
--amber-hover: #FBBF24;
|
||||
--amber-dim: #78350F;
|
||||
|
||||
--rec-red: #EF4444;
|
||||
--rec-red-hover: #F87171;
|
||||
--rec-red-dim: #7F1D1D;
|
||||
|
||||
--red: #E22134;
|
||||
--red-hover: #FF4055;
|
||||
|
||||
@@ -233,6 +241,14 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%);
|
||||
}
|
||||
|
||||
.hero-amber {
|
||||
background: linear-gradient(135deg, #F59E0B 0%, #78350F 60%, #3B1E07 100%);
|
||||
}
|
||||
|
||||
.hero-red {
|
||||
background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%);
|
||||
}
|
||||
|
||||
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
||||
|
||||
.hero-eyebrow {
|
||||
@@ -246,6 +262,8 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
.hero-eyebrow.blue { color: var(--blue); }
|
||||
.hero-eyebrow.purple { color: var(--purple); }
|
||||
.hero-eyebrow.pink { color: var(--pink-hover); }
|
||||
.hero-eyebrow.amber { color: var(--amber-hover); }
|
||||
.hero-eyebrow.red { color: var(--rec-red-hover); }
|
||||
|
||||
.hero-title {
|
||||
font-size: 36px;
|
||||
@@ -463,6 +481,25 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-amber {
|
||||
background: var(--amber);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-red {
|
||||
background: var(--rec-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-red.recording {
|
||||
animation: pulseRed 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseRed {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #00CEC9;
|
||||
color: #000;
|
||||
@@ -819,3 +856,136 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
|
||||
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
|
||||
.main::-webkit-scrollbar-thumb { border-color: var(--bg); }
|
||||
|
||||
/* ===============================
|
||||
METADATA EDITOR
|
||||
=============================== */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
|
||||
.form-grid .field { margin-bottom: 0; }
|
||||
.form-grid .field.full { grid-column: 1 / -1; }
|
||||
|
||||
.textarea {
|
||||
border-radius: var(--r-md);
|
||||
padding: 12px 16px;
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 22px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--divider);
|
||||
color: var(--text-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.meta-info span strong {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.cover-card {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-card-2);
|
||||
border: 1px dashed var(--bg-input-hover);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cover-empty {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.cover-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cover-hint {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cover-hint.set { color: var(--amber-hover); font-weight: 700; }
|
||||
.cover-hint.remove { color: var(--red); font-weight: 700; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.cover-card { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
RECORDER
|
||||
=============================== */
|
||||
.rec-timer {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--rec-red-hover);
|
||||
letter-spacing: 1px;
|
||||
margin-left: auto;
|
||||
min-width: 130px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rec-status {
|
||||
color: var(--text-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
margin-left: 18px;
|
||||
}
|
||||
|
||||
.rec-result {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.rec-result .badge-dur {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--rec-red-hover);
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
margin-right: 10px;
|
||||
}
|
||||
@@ -33,6 +33,14 @@
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="meta">
|
||||
<span class="nav-icon">🏷</span>
|
||||
<span>Metadati</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="record">
|
||||
<span class="nav-icon">●</span>
|
||||
<span>Registra</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="settings">
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span>Impostazioni</span>
|
||||
@@ -237,6 +245,172 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: REGISTRA ===== -->
|
||||
<section class="view" id="view-record">
|
||||
<header class="hero hero-red">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow red">REGISTRAZIONE</div>
|
||||
<h1 class="hero-title">Cattura audio</h1>
|
||||
<p class="hero-subtitle">Registra in MP3 320 kbps da qualsiasi ingresso audio (mixer, scheda, microfono, BlackHole/loopback)</p>
|
||||
</div>
|
||||
<div class="hero-deco">●</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Dispositivo</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Ingresso audio</label>
|
||||
<div class="row">
|
||||
<select id="recDevice" class="input pill">
|
||||
<option value="">Caricamento…</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost pill btn-sm" id="recRefreshBtn">↻ Aggiorna</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint" id="recHint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
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>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Destinazione</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella output</label>
|
||||
<div class="row">
|
||||
<div class="path-display" id="recPathDisplay">Nessuna cartella selezionata</div>
|
||||
<button class="btn btn-ghost pill btn-sm" id="recBrowseBtn">Sfoglia</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Nome file (opzionale)</label>
|
||||
<input type="text" id="recFilename" class="input pill" placeholder="Registrazione_AAAAMMGG_HHMMSS.mp3" />
|
||||
</div>
|
||||
|
||||
<div class="field inline">
|
||||
<label class="field-label">Bitrate</label>
|
||||
<select id="recBitrate" class="input pill input-sm">
|
||||
<option value="128k">128 kbps</option>
|
||||
<option value="192k">192 kbps</option>
|
||||
<option value="256k">256 kbps</option>
|
||||
<option value="320k" selected>320 kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button class="btn btn-red pill btn-lg" id="recStartBtn">
|
||||
<span class="ico">●</span> REC
|
||||
</button>
|
||||
<button class="btn btn-danger pill btn-lg" id="recStopBtn" disabled>
|
||||
<span class="ico">◼</span> Stop
|
||||
</button>
|
||||
<div class="rec-timer" id="recTimer">00:00:00</div>
|
||||
<div class="rec-status" id="recStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="recOutputCard" hidden>
|
||||
<div class="micro-label">Ultima registrazione</div>
|
||||
<div class="rec-result" id="recResult"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: METADATI ===== -->
|
||||
<section class="view" id="view-meta">
|
||||
<header class="hero hero-amber">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow amber">METADATI</div>
|
||||
<h1 class="hero-title">Modifica tag audio</h1>
|
||||
<p class="hero-subtitle">Titolo · Artista · Album · Anno · Copertina · BPM · Key · MP3/M4A/FLAC</p>
|
||||
</div>
|
||||
<div class="hero-deco">🏷</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">File</h2>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
|
||||
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
|
||||
</div>
|
||||
<div class="meta-info" id="metaInfo" hidden></div>
|
||||
</div>
|
||||
|
||||
<div id="metaForm" hidden>
|
||||
<h2 class="section-label">Tag</h2>
|
||||
<div class="card">
|
||||
<div class="form-grid">
|
||||
<div class="field full">
|
||||
<label class="field-label">Titolo</label>
|
||||
<input type="text" id="metaTitle" class="input pill">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Artista</label>
|
||||
<input type="text" id="metaArtist" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Album</label>
|
||||
<input type="text" id="metaAlbum" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Album Artist</label>
|
||||
<input type="text" id="metaAlbumArtist" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Anno</label>
|
||||
<input type="text" id="metaYear" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Traccia</label>
|
||||
<input type="text" id="metaTrack" class="input pill" placeholder="1/12">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Genere</label>
|
||||
<input type="text" id="metaGenre" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">BPM</label>
|
||||
<input type="text" id="metaBpm" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Key</label>
|
||||
<input type="text" id="metaKey" class="input pill">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Commento</label>
|
||||
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field full" id="metaWhereFromField" hidden>
|
||||
<label class="field-label">
|
||||
Origine (macOS · kMDItemWhereFroms)
|
||||
</label>
|
||||
<textarea id="metaWhereFrom" class="input textarea" rows="2" placeholder="https://... (uno per riga)"></textarea>
|
||||
<div class="cover-hint">Extended attribute macOS — una URL per riga. Vuoto = rimuove l'attributo.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Copertina</h2>
|
||||
<div class="card cover-card">
|
||||
<div class="cover-preview" id="metaCoverPreview">
|
||||
<span class="cover-empty">Nessuna copertina</span>
|
||||
</div>
|
||||
<div class="cover-actions">
|
||||
<button class="btn btn-ghost pill" id="metaCoverBtn">📂 Cambia copertina</button>
|
||||
<button class="btn btn-ghost pill" id="metaCoverRemoveBtn">✕ Rimuovi</button>
|
||||
<div class="cover-hint" id="metaCoverHint"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button class="btn btn-amber pill btn-lg" id="metaSaveBtn">💾 Salva tag</button>
|
||||
<button class="btn btn-ghost pill btn-lg" id="metaReloadBtn">↺ Ricarica</button>
|
||||
<div class="counter" id="metaStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: SETTINGS ===== -->
|
||||
<section class="view" id="view-settings">
|
||||
<header class="hero hero-purple">
|
||||
|
||||
+304
-19
@@ -7,7 +7,7 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
const state = {
|
||||
config: {},
|
||||
urlList: [],
|
||||
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
||||
dlOutputDir: "",
|
||||
upDir: "",
|
||||
videoOutputDir: "",
|
||||
@@ -15,6 +15,8 @@ const state = {
|
||||
downloading: false,
|
||||
upgrading: false,
|
||||
videoDownloading: false,
|
||||
meta: { path: "", newCoverPath: "", removeCover: false },
|
||||
rec: { devices: [], outputDir: "", recording: false, seconds: 0 },
|
||||
};
|
||||
|
||||
// Wait for pywebview ready
|
||||
@@ -55,7 +57,7 @@ const bridgeHandlers = {
|
||||
|
||||
"download:done": () => {
|
||||
state.downloading = false;
|
||||
state.urlList = [];
|
||||
state.loaded = null;
|
||||
$("#urlListBadge").hidden = true;
|
||||
$("#urlListBadge").textContent = "";
|
||||
$("#downloadBtn").disabled = false;
|
||||
@@ -103,8 +105,56 @@ const bridgeHandlers = {
|
||||
$("#videoDownloadBtn").disabled = false;
|
||||
$("#stopVideoBtn").disabled = true;
|
||||
},
|
||||
|
||||
"recording:event": (p) => {
|
||||
const status = p.status;
|
||||
if (status === "started") {
|
||||
state.rec.recording = true;
|
||||
state.rec.seconds = 0;
|
||||
$("#recStartBtn").disabled = true;
|
||||
$("#recStartBtn").classList.add("recording");
|
||||
$("#recStopBtn").disabled = false;
|
||||
$("#recDevice").disabled = true;
|
||||
$("#recRefreshBtn").disabled = true;
|
||||
$("#recTimer").textContent = "00:00:00";
|
||||
$("#recStatus").textContent = "● Registrazione in corso…";
|
||||
} else if (status === "tick") {
|
||||
state.rec.seconds = p.seconds || 0;
|
||||
$("#recTimer").textContent = formatHms(state.rec.seconds);
|
||||
} else if (status === "stopped") {
|
||||
state.rec.recording = false;
|
||||
$("#recStartBtn").disabled = false;
|
||||
$("#recStartBtn").classList.remove("recording");
|
||||
$("#recStopBtn").disabled = true;
|
||||
$("#recDevice").disabled = false;
|
||||
$("#recRefreshBtn").disabled = false;
|
||||
$("#recStatus").textContent = "";
|
||||
$("#recOutputCard").hidden = false;
|
||||
$("#recResult").innerHTML = `
|
||||
<span class="badge-dur">${formatHms(p.seconds || 0)}</span>
|
||||
${p.output_path || ""}
|
||||
`;
|
||||
toast("Registrazione salvata", "success");
|
||||
} else if (status === "error") {
|
||||
state.rec.recording = false;
|
||||
$("#recStartBtn").disabled = false;
|
||||
$("#recStartBtn").classList.remove("recording");
|
||||
$("#recStopBtn").disabled = true;
|
||||
$("#recDevice").disabled = false;
|
||||
$("#recRefreshBtn").disabled = false;
|
||||
$("#recStatus").textContent = "";
|
||||
toast("Errore registrazione: " + (p.error || ""), "error");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function formatHms(sec) {
|
||||
const h = Math.floor(sec / 3600).toString().padStart(2, "0");
|
||||
const m = Math.floor((sec % 3600) / 60).toString().padStart(2, "0");
|
||||
const s = Math.floor(sec % 60).toString().padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Logging
|
||||
// ============================================================
|
||||
@@ -195,6 +245,34 @@ async function init() {
|
||||
|
||||
// Upgrade tab — default threshold
|
||||
$("#upThreshold").value = state.config.hq_threshold || 310;
|
||||
|
||||
// Record tab — default output dir + carica dispositivi
|
||||
state.rec.outputDir = state.config.output_dir || "";
|
||||
if (state.rec.outputDir) {
|
||||
$("#recPathDisplay").textContent = state.rec.outputDir;
|
||||
$("#recPathDisplay").classList.remove("empty");
|
||||
}
|
||||
await refreshRecDevices();
|
||||
}
|
||||
|
||||
async function refreshRecDevices() {
|
||||
const res = await window.pywebview.api.list_audio_inputs();
|
||||
const select = $("#recDevice");
|
||||
select.innerHTML = "";
|
||||
state.rec.devices = res.devices || [];
|
||||
if (!state.rec.devices.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "Nessun dispositivo trovato";
|
||||
select.appendChild(opt);
|
||||
return;
|
||||
}
|
||||
for (const d of state.rec.devices) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = d.id;
|
||||
opt.textContent = d.is_virtual ? `🔄 ${d.name} (loopback)` : d.name;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -221,27 +299,23 @@ $("#loadListBtn").addEventListener("click", async () => {
|
||||
return;
|
||||
}
|
||||
if (!res.count) {
|
||||
toast("Il file non contiene URL validi", "error");
|
||||
toast("Il file non contiene voci valide", "error");
|
||||
return;
|
||||
}
|
||||
state.urlList = res.urls;
|
||||
state.loaded = res;
|
||||
$("#urlInput").value = path;
|
||||
const badge = $("#urlListBadge");
|
||||
badge.textContent = `· ${res.count} URL caricati`;
|
||||
if (res.kind === "tracks") {
|
||||
badge.textContent = `· ${res.count} tracce`;
|
||||
appendLog("download", `[INFO] Caricata tracklist: ${res.count} brani`);
|
||||
} else {
|
||||
badge.textContent = `· ${res.count} URL`;
|
||||
appendLog("download", `[INFO] Caricati ${res.count} URL dal file`);
|
||||
}
|
||||
badge.hidden = false;
|
||||
appendLog("download", `[INFO] Caricati ${res.count} URL dal file`);
|
||||
});
|
||||
|
||||
$("#downloadBtn").addEventListener("click", async () => {
|
||||
let urls = state.urlList;
|
||||
if (!urls.length) {
|
||||
const single = $("#urlInput").value.trim();
|
||||
if (!single) {
|
||||
toast("Inserisci un URL o carica una lista", "error");
|
||||
return;
|
||||
}
|
||||
urls = [single];
|
||||
}
|
||||
if (!state.dlOutputDir) {
|
||||
toast("Seleziona una cartella di destinazione", "error");
|
||||
return;
|
||||
@@ -254,10 +328,30 @@ $("#downloadBtn").addEventListener("click", async () => {
|
||||
$("#dlProgressFill").style.width = "0%";
|
||||
$("#dlPercent").textContent = "0%";
|
||||
|
||||
const res = await window.pywebview.api.start_download({
|
||||
urls,
|
||||
output_dir: state.dlOutputDir,
|
||||
});
|
||||
let res;
|
||||
if (state.loaded && state.loaded.kind === "tracks") {
|
||||
res = await window.pywebview.api.start_tracks_download({
|
||||
tracks: state.loaded.tracks,
|
||||
output_dir: state.dlOutputDir,
|
||||
subfolder: state.loaded.name || "",
|
||||
});
|
||||
} else {
|
||||
let urls = state.loaded && state.loaded.kind === "urls" ? state.loaded.urls : [];
|
||||
if (!urls.length) {
|
||||
const single = $("#urlInput").value.trim();
|
||||
if (!single) {
|
||||
toast("Inserisci un URL o carica una lista", "error");
|
||||
bridgeHandlers["download:done"]();
|
||||
return;
|
||||
}
|
||||
urls = [single];
|
||||
}
|
||||
res = await window.pywebview.api.start_download({
|
||||
urls,
|
||||
output_dir: state.dlOutputDir,
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["download:done"]();
|
||||
@@ -375,6 +469,197 @@ $("#stopUpgradeBtn").addEventListener("click", async () => {
|
||||
$("#stopUpgradeBtn").disabled = true;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// RECORDER tab
|
||||
// ============================================================
|
||||
$("#recRefreshBtn").addEventListener("click", refreshRecDevices);
|
||||
|
||||
$("#recBrowseBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.browse_directory();
|
||||
if (path) {
|
||||
state.rec.outputDir = path;
|
||||
$("#recPathDisplay").textContent = path;
|
||||
$("#recPathDisplay").classList.remove("empty");
|
||||
}
|
||||
});
|
||||
|
||||
$("#recStartBtn").addEventListener("click", async () => {
|
||||
const deviceId = $("#recDevice").value;
|
||||
if (!deviceId) {
|
||||
toast("Seleziona un dispositivo", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.rec.outputDir) {
|
||||
toast("Seleziona una cartella di destinazione", "error");
|
||||
return;
|
||||
}
|
||||
const res = await window.pywebview.api.start_audio_recording({
|
||||
device_id: deviceId,
|
||||
output_dir: state.rec.outputDir,
|
||||
filename: $("#recFilename").value.trim(),
|
||||
bitrate: $("#recBitrate").value,
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
}
|
||||
});
|
||||
|
||||
$("#recStopBtn").addEventListener("click", async () => {
|
||||
$("#recStopBtn").disabled = true;
|
||||
await window.pywebview.api.stop_audio_recording();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// METADATA editor
|
||||
// ============================================================
|
||||
function formatDuration(sec) {
|
||||
if (!sec) return "—";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function populateMetaForm(d) {
|
||||
$("#metaTitle").value = d.title || "";
|
||||
$("#metaArtist").value = d.artist || "";
|
||||
$("#metaAlbum").value = d.album || "";
|
||||
$("#metaAlbumArtist").value = d.album_artist || "";
|
||||
$("#metaYear").value = d.year || "";
|
||||
$("#metaTrack").value = d.track || "";
|
||||
$("#metaGenre").value = d.genre || "";
|
||||
$("#metaBpm").value = d.bpm || "";
|
||||
$("#metaKey").value = d.key || "";
|
||||
$("#metaComment").value = d.comment || "";
|
||||
|
||||
// WhereFroms (macOS only)
|
||||
if (d.is_macos) {
|
||||
$("#metaWhereFromField").hidden = false;
|
||||
$("#metaWhereFrom").value = (d.where_from || []).join("\n");
|
||||
} else {
|
||||
$("#metaWhereFromField").hidden = true;
|
||||
$("#metaWhereFrom").value = "";
|
||||
}
|
||||
|
||||
// Info riga sotto il path
|
||||
const info = $("#metaInfo");
|
||||
info.hidden = false;
|
||||
info.innerHTML = `
|
||||
<span><strong>Formato:</strong>${d.format || "—"}</span>
|
||||
<span><strong>Bitrate:</strong>${d.bitrate ? d.bitrate + " kbps" : "—"}</span>
|
||||
<span><strong>Durata:</strong>${formatDuration(d.duration)}</span>
|
||||
`;
|
||||
|
||||
// Cover
|
||||
const preview = $("#metaCoverPreview");
|
||||
preview.innerHTML = "";
|
||||
if (d.cover_base64) {
|
||||
const img = document.createElement("img");
|
||||
img.src = `data:${d.cover_mime || "image/jpeg"};base64,${d.cover_base64}`;
|
||||
preview.appendChild(img);
|
||||
} else {
|
||||
const span = document.createElement("span");
|
||||
span.className = "cover-empty";
|
||||
span.textContent = "Nessuna copertina";
|
||||
preview.appendChild(span);
|
||||
}
|
||||
$("#metaCoverHint").textContent = "";
|
||||
$("#metaCoverHint").className = "cover-hint";
|
||||
|
||||
state.meta.newCoverPath = "";
|
||||
state.meta.removeCover = false;
|
||||
}
|
||||
|
||||
async function loadMetaFile(path) {
|
||||
const res = await window.pywebview.api.read_metadata(path);
|
||||
if (!res.ok) {
|
||||
toast("Errore lettura: " + (res.error || ""), "error");
|
||||
return;
|
||||
}
|
||||
state.meta.path = path;
|
||||
$("#metaPathDisplay").textContent = path;
|
||||
$("#metaPathDisplay").classList.remove("empty");
|
||||
$("#metaForm").hidden = false;
|
||||
populateMetaForm(res.data);
|
||||
}
|
||||
|
||||
$("#metaPickBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.pick_audio_file();
|
||||
if (path) await loadMetaFile(path);
|
||||
});
|
||||
|
||||
$("#metaCoverBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.pick_image_file();
|
||||
if (!path) return;
|
||||
state.meta.newCoverPath = path;
|
||||
state.meta.removeCover = false;
|
||||
|
||||
// Mostra anteprima leggendo il file via fetch (file:// non funziona da webview;
|
||||
// usiamo solo il nome come hint, l'utente vedra il risultato dopo Save)
|
||||
const hint = $("#metaCoverHint");
|
||||
hint.textContent = "Nuova copertina: " + path.split("/").pop();
|
||||
hint.className = "cover-hint set";
|
||||
});
|
||||
|
||||
$("#metaCoverRemoveBtn").addEventListener("click", () => {
|
||||
state.meta.newCoverPath = "";
|
||||
state.meta.removeCover = true;
|
||||
const preview = $("#metaCoverPreview");
|
||||
preview.innerHTML = '<span class="cover-empty">Verra rimossa al salvataggio</span>';
|
||||
const hint = $("#metaCoverHint");
|
||||
hint.textContent = "Copertina marcata per rimozione";
|
||||
hint.className = "cover-hint remove";
|
||||
});
|
||||
|
||||
$("#metaReloadBtn").addEventListener("click", async () => {
|
||||
if (state.meta.path) await loadMetaFile(state.meta.path);
|
||||
});
|
||||
|
||||
$("#metaSaveBtn").addEventListener("click", async () => {
|
||||
if (!state.meta.path) {
|
||||
toast("Nessun file caricato", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
$("#metaSaveBtn").disabled = true;
|
||||
const status = $("#metaStatus");
|
||||
status.textContent = "Salvataggio…";
|
||||
|
||||
const payload = {
|
||||
path: state.meta.path,
|
||||
data: {
|
||||
title: $("#metaTitle").value,
|
||||
artist: $("#metaArtist").value,
|
||||
album: $("#metaAlbum").value,
|
||||
album_artist: $("#metaAlbumArtist").value,
|
||||
year: $("#metaYear").value,
|
||||
track: $("#metaTrack").value,
|
||||
genre: $("#metaGenre").value,
|
||||
bpm: $("#metaBpm").value,
|
||||
key: $("#metaKey").value,
|
||||
comment: $("#metaComment").value,
|
||||
where_from: $("#metaWhereFrom").value
|
||||
.split("\n")
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
cover_path: state.meta.newCoverPath,
|
||||
remove_cover: state.meta.removeCover,
|
||||
};
|
||||
|
||||
const res = await window.pywebview.api.save_metadata(payload);
|
||||
$("#metaSaveBtn").disabled = false;
|
||||
|
||||
if (res.ok) {
|
||||
status.textContent = "✓ Tag salvati";
|
||||
toast("Tag salvati", "success");
|
||||
// Ricarica per mostrare lo stato aggiornato (es. nuova cover)
|
||||
await loadMetaFile(state.meta.path);
|
||||
} else {
|
||||
status.textContent = "";
|
||||
toast("Errore: " + (res.error || ""), "error");
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SETTINGS tab
|
||||
// ============================================================
|
||||
|
||||
Reference in new issue
Block a user