Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8423ec215 | ||
|
|
4b1524e5f7 | ||
|
|
e5158d382f | ||
|
|
8d9c05b151 |
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,112 @@
|
|||||||
# MusicDownload
|
# 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** da Spotify, YouTube e SoundCloud, e **video** da YouTube, TikTok, Instagram e Facebook. Include anche un upgrader di qualità per migliorare i file audio già scaricati.
|
||||||
|
|
||||||
|
UI moderna basata su **pywebview** (WebKit su macOS, EdgeChromium su Windows) — frontend HTML/CSS/JS, backend Python.
|
||||||
|
|
||||||
Sviluppata da **LuZa**.
|
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
|
## Funzionalità
|
||||||
- **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
|
- **Scarica musica** — Incolla un link Spotify (playlist/album/brano), YouTube, SoundCloud o qualsiasi sorgente yt-dlp e scarica in MP3 a 320 kbps. Supporta liste di URL da file `.txt`.
|
||||||
- **Aggiornamento app** — Controlla la disponibilita di nuove versioni direttamente dalle Impostazioni
|
- **Scarica video** — Incolla un link **YouTube · TikTok · Instagram · Facebook** (o qualsiasi sorgente yt-dlp) e scarica in MP4 alla qualità scelta (best / 1080p / 720p / 480p).
|
||||||
- **Gestione impostazioni** — Credenziali Spotify, bitrate, soglia HQ, percorsi, tema
|
- **Upgrade qualità** — Scansiona una cartella di file audio e riscarica quelli sotto la soglia kbps impostata.
|
||||||
|
- **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.
|
||||||
|
- **Aggiornamento automatico** — Verifica disponibilità nuove versioni dalla pagina Impostazioni.
|
||||||
|
- **Lista multi-URL** — Carica un file `.txt` con un URL per riga per scaricare più playlist in sequenza.
|
||||||
|
|
||||||
## Requisiti
|
## Requisiti
|
||||||
|
|
||||||
- Python 3.8+
|
- Python 3.8+
|
||||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) installato nel sistema (`brew install yt-dlp` su macOS)
|
- `ffmpeg` + `ffprobe` (solo per build/sviluppo locale: `brew install ffmpeg` su macOS)
|
||||||
- [ffmpeg](https://ffmpeg.org/) installato nel sistema (`brew install ffmpeg` su macOS)
|
- Credenziali Spotify API gratuite — solo se si usano URL Spotify
|
||||||
- Credenziali Spotify API (gratuite, vedi sotto) — necessarie solo per URL Spotify
|
|
||||||
|
|
||||||
## Installazione
|
Nelle build distribuite (`.app` / `.exe`) **tutto è incluso** — non serve installare nulla.
|
||||||
|
|
||||||
|
## Installazione (sviluppo)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://github.com/luzadev/musicdownload.git
|
||||||
|
cd musicdownload
|
||||||
pip install -r requirements.txt
|
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
|
python3 main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Dipendenze principali:
|
||||||
|
|
||||||
|
| Pacchetto | Utilizzo |
|
||||||
|
|---|---|
|
||||||
|
| `pywebview` | Finestra nativa con WebView (WebKit / EdgeChromium) |
|
||||||
|
| `requests` | Chiamate API Spotify |
|
||||||
|
| `yt-dlp` | Download audio/video da YouTube e oltre 1000 siti |
|
||||||
|
| `pyobjc-framework-WebKit` (macOS) | Backend WebKit di pywebview |
|
||||||
|
| `pythonnet` (Windows) | Backend EdgeChromium di pywebview |
|
||||||
|
|
||||||
## Credenziali Spotify API
|
## 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**
|
2. Clicca **Create app**
|
||||||
3. Compila: nome a piacere, Redirect URI `http://localhost:8888/callback`, seleziona **Web API**
|
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
|
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**.
|
||||||
|
|
||||||
## Struttura progetto
|
## Struttura progetto
|
||||||
|
|
||||||
```
|
```
|
||||||
MusicDownload/
|
MusicDownload/
|
||||||
├── main.py # Entry point
|
├── main.py # Entry point — bootstrap pywebview
|
||||||
├── requirements.txt # Dipendenze Python
|
├── requirements.txt
|
||||||
├── build_macos.py # Script build macOS (.app)
|
├── build_macos.py # Script build macOS (.app)
|
||||||
├── build_windows.py # Script build Windows (.exe)
|
├── 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)
|
│ ├── config.py # Configurazione persistente (JSON)
|
||||||
│ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg)
|
│ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg)
|
||||||
│ ├── spotify_client.py # Autenticazione e API Spotify
|
│ ├── spotify_client.py # API Spotify (Client Credentials)
|
||||||
│ ├── downloader.py # Download brani (Spotify via YouTube + URL diretti yt-dlp)
|
│ ├── downloader.py # Download audio (playlist Spotify, URL diretti, video)
|
||||||
│ └── upgrader.py # Upgrade qualita file esistenti
|
│ └── upgrader.py # Upgrade qualità file esistenti
|
||||||
│
|
│
|
||||||
└── gui/ # Interfaccia grafica
|
├── api/
|
||||||
├── app.py # Finestra principale con sidebar
|
│ └── bridge.py # Ponte JS <-> Python (pywebview.js_api)
|
||||||
├── download_tab.py # Pagina download
|
│
|
||||||
├── upgrade_tab.py # Pagina upgrade
|
└── webui/ # Frontend
|
||||||
└── settings_tab.py # Pagina impostazioni
|
├── index.html # Single-page, 4 view (Scarica / Video / Upgrade / Impostazioni)
|
||||||
|
├── css/style.css # Tema scuro Spotify-like, gradient hero, animazioni
|
||||||
|
└── js/app.js # Routing, eventi, chiamate API
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configurazione
|
## Configurazione
|
||||||
|
|
||||||
Il file `config.json` viene creato automaticamente:
|
Il file `config.json` viene creato automaticamente al primo avvio:
|
||||||
|
|
||||||
| Chiave | Default | Descrizione |
|
| Chiave | Default | Descrizione |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `client_id` | `""` | Spotify Client ID |
|
| `client_id` | `""` | Spotify Client ID |
|
||||||
| `client_secret` | `""` | Spotify Client Secret |
|
| `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 |
|
| `hq_threshold` | `310` | Soglia kbps per considerare un file HQ |
|
||||||
| `cookies_path` | `"cookies.txt"` | Path al file cookies YouTube (opzionale) |
|
| `cookies_path` | `""` | Path al file `cookies.txt` (opzionale) |
|
||||||
| `output_dir` | `"MUSICA/"` | Cartella output predefinita |
|
| `output_dir` | `""` | Cartella output predefinita |
|
||||||
| `theme` | `"dark"` | Tema interfaccia (dark/light/system) |
|
| `theme` | `"dark"` | Tema interfaccia |
|
||||||
|
|
||||||
Posizione del file:
|
Posizione:
|
||||||
- **Sviluppo**: directory del progetto
|
- **Sviluppo**: directory del progetto
|
||||||
- **macOS bundle**: `~/Library/Application Support/MusicDownload/`
|
- **macOS bundle**: `~/Library/Application Support/MusicDownload/`
|
||||||
- **Windows bundle**: `%APPDATA%/MusicDownload/`
|
- **Windows bundle**: `%APPDATA%/MusicDownload/`
|
||||||
|
|
||||||
## Build
|
## Build locale
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
@@ -102,7 +116,7 @@ pip install pyinstaller
|
|||||||
python3 build_macos.py
|
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
|
### Windows
|
||||||
|
|
||||||
@@ -111,22 +125,45 @@ pip install pyinstaller
|
|||||||
python build_windows.py
|
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.2.0
|
||||||
|
git push origin v1.2.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
|
## Aggiornamento app
|
||||||
|
|
||||||
L'app verifica la disponibilita di nuove versioni tramite un file `version.json` remoto. Il formato atteso:
|
L'app verifica nuove versioni interrogando un `version.json` remoto. URL configurato in `api/bridge.py` (`UPDATE_URL` nel metodo `check_update`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": "v1.1",
|
"version": "v1.2.0",
|
||||||
"download_url": "https://www.djluza.com/musicdownload/MusicDownload.dmg",
|
"download_url": "https://github.com/luzadev/musicdownload/releases/latest",
|
||||||
"notes": "Descrizione delle novita"
|
"notes": "Descrizione delle novità"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
L'URL di controllo e configurabile nella costante `UPDATE_URL` in `gui/settings_tab.py`.
|
|
||||||
|
|
||||||
## Licenza
|
## Licenza
|
||||||
|
|
||||||
Uso personale.
|
Uso personale.
|
||||||
+241
-4
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
@@ -13,6 +14,7 @@ from core.config import load_config, save_config, VERSION
|
|||||||
from core.downloader import (
|
from core.downloader import (
|
||||||
download_playlist,
|
download_playlist,
|
||||||
download_direct_url,
|
download_direct_url,
|
||||||
|
download_video,
|
||||||
request_stop as request_download_stop,
|
request_stop as request_download_stop,
|
||||||
reset_stop as reset_download_stop,
|
reset_stop as reset_download_stop,
|
||||||
is_stopped as download_is_stopped,
|
is_stopped as download_is_stopped,
|
||||||
@@ -48,6 +50,30 @@ def _js_safe(value: Any) -> str:
|
|||||||
return json.dumps(value, ensure_ascii=False)
|
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:
|
class Api:
|
||||||
"""Espone i metodi a JavaScript tramite pywebview js_api."""
|
"""Espone i metodi a JavaScript tramite pywebview js_api."""
|
||||||
|
|
||||||
@@ -55,6 +81,7 @@ class Api:
|
|||||||
self.window = None # impostato dopo create_window
|
self.window = None # impostato dopo create_window
|
||||||
self._download_thread: Optional[threading.Thread] = None
|
self._download_thread: Optional[threading.Thread] = None
|
||||||
self._upgrade_thread: Optional[threading.Thread] = None
|
self._upgrade_thread: Optional[threading.Thread] = None
|
||||||
|
self._video_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -165,10 +192,29 @@ class Api:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def load_url_list(self, path: str) -> dict:
|
def load_url_list(self, path: str) -> dict:
|
||||||
|
"""Carica un file .txt. Riconosce automaticamente se contiene URL
|
||||||
|
oppure una tracklist 'Artista - Titolo'."""
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
urls = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
lines = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
||||||
return {"ok": True, "urls": urls, "count": len(urls)}
|
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:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
@@ -188,9 +234,15 @@ class Api:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# DOWNLOAD
|
# DOWNLOAD
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
def _any_job_running(self) -> bool:
|
||||||
|
return (
|
||||||
|
(self._download_thread and self._download_thread.is_alive()) or
|
||||||
|
(self._video_thread and self._video_thread.is_alive())
|
||||||
|
)
|
||||||
|
|
||||||
def start_download(self, payload: dict) -> dict:
|
def start_download(self, payload: dict) -> dict:
|
||||||
if self._download_thread and self._download_thread.is_alive():
|
if self._any_job_running():
|
||||||
return {"ok": False, "error": "Download gia in corso"}
|
return {"ok": False, "error": "Un download gia in corso"}
|
||||||
|
|
||||||
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()
|
||||||
@@ -207,11 +259,92 @@ class Api:
|
|||||||
self._download_thread.start()
|
self._download_thread.start()
|
||||||
return {"ok": True}
|
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:
|
def stop_download(self) -> dict:
|
||||||
request_download_stop()
|
request_download_stop()
|
||||||
self._log("download", "[INFO] Interruzione richiesta...")
|
self._log("download", "[INFO] Interruzione richiesta...")
|
||||||
return {"ok": True}
|
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:
|
def _download_worker(self, urls: list, output_dir: str) -> None:
|
||||||
reset_download_stop()
|
reset_download_stop()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -388,3 +521,107 @@ class Api:
|
|||||||
|
|
||||||
upgrade_folder(directory, threshold, cookies_path, recursive, progress_cb)
|
upgrade_folder(directory, threshold, cookies_path, recursive, progress_cb)
|
||||||
self._emit("upgrade:done", {"ok": True})
|
self._emit("upgrade:done", {"ok": True})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# VIDEO (YouTube, TikTok, Instagram, Facebook)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def start_video_download(self, payload: dict) -> dict:
|
||||||
|
if self._any_job_running():
|
||||||
|
return {"ok": False, "error": "Un download gia in corso"}
|
||||||
|
|
||||||
|
urls = payload.get("urls") or []
|
||||||
|
output_dir = (payload.get("output_dir") or "").strip()
|
||||||
|
quality = payload.get("quality") or "1080p"
|
||||||
|
|
||||||
|
if not urls:
|
||||||
|
return {"ok": False, "error": "Nessun URL fornito"}
|
||||||
|
if not output_dir:
|
||||||
|
return {"ok": False, "error": "Cartella output non impostata"}
|
||||||
|
|
||||||
|
self._video_thread = threading.Thread(
|
||||||
|
target=self._video_worker,
|
||||||
|
args=(list(urls), output_dir, quality),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._video_thread.start()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def stop_video_download(self) -> dict:
|
||||||
|
request_download_stop()
|
||||||
|
self._log("video", "[INFO] Interruzione richiesta...")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def _video_worker(self, urls: list, output_dir: str, quality: str) -> None:
|
||||||
|
reset_download_stop()
|
||||||
|
cfg = load_config()
|
||||||
|
cookies_path = cfg.get("cookies_path", "")
|
||||||
|
total_urls = len(urls)
|
||||||
|
multi = total_urls > 1
|
||||||
|
view = "video"
|
||||||
|
|
||||||
|
for url_idx, url in enumerate(urls):
|
||||||
|
if download_is_stopped():
|
||||||
|
self._log(view, "[INFO] Download interrotto.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if multi:
|
||||||
|
self._log(view, f"\n{'='*50}")
|
||||||
|
self._log(view, f"[LISTA] URL {url_idx + 1} di {total_urls}")
|
||||||
|
self._log(view, f"{'='*50}")
|
||||||
|
|
||||||
|
_last = [0.0]
|
||||||
|
_THROTTLE = 0.10
|
||||||
|
url_base = url_idx / total_urls if multi else 0
|
||||||
|
url_weight = 1 / total_urls if multi else 1
|
||||||
|
|
||||||
|
def progress_cb(idx, total, track_name, status, pct,
|
||||||
|
_base=url_base, _weight=url_weight, _uidx=url_idx):
|
||||||
|
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": _uidx, "url_total": total_urls,
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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(_base + track_progress * _weight, 1.0)
|
||||||
|
elif status == "downloading":
|
||||||
|
track_progress = (idx / total) + (pct / 100 / total) if total > 0 else 0
|
||||||
|
payload_evt["overall"] = min(_base + track_progress * _weight, 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(_base + track_progress * _weight, 1.0)
|
||||||
|
elif status == "stopped":
|
||||||
|
self._log(view, "[INFO] Download interrotto.")
|
||||||
|
elif status == "completed":
|
||||||
|
if not multi:
|
||||||
|
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("video:progress", payload_evt)
|
||||||
|
|
||||||
|
self._log(view, f"[INFO] Video: {url}")
|
||||||
|
try:
|
||||||
|
download_video(url, output_dir, quality, cookies_path, progress_cb)
|
||||||
|
except Exception as e:
|
||||||
|
self._log(view, f"[ERRORE] {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if multi and not download_is_stopped():
|
||||||
|
self._log(view, f"\n[INFO] Completate tutte le {total_urls} URL!")
|
||||||
|
self._emit("video:progress", {
|
||||||
|
"status": "completed", "overall": 1.0,
|
||||||
|
"url_total": total_urls,
|
||||||
|
})
|
||||||
|
|
||||||
|
self._emit("video:done", {"ok": True})
|
||||||
@@ -393,3 +393,149 @@ def download_direct_url(
|
|||||||
|
|
||||||
if progress_callback and not is_stopped():
|
if progress_callback and not is_stopped():
|
||||||
progress_callback(total, total, "", "completed", 100)
|
progress_callback(total, total, "", "completed", 100)
|
||||||
|
|
||||||
|
|
||||||
|
def _video_format(quality: str) -> str:
|
||||||
|
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
||||||
|
if not quality or quality == "best":
|
||||||
|
return "bv*+ba/b"
|
||||||
|
h = quality.rstrip("pP")
|
||||||
|
try:
|
||||||
|
h_int = int(h)
|
||||||
|
except ValueError:
|
||||||
|
h_int = 1080
|
||||||
|
return (
|
||||||
|
f"bv*[height<=?{h_int}][ext=mp4]+ba[ext=m4a]/"
|
||||||
|
f"bv*[height<=?{h_int}]+ba/"
|
||||||
|
f"b[height<=?{h_int}][ext=mp4]/"
|
||||||
|
f"b[height<=?{h_int}]/b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_video(
|
||||||
|
url: str,
|
||||||
|
output_dir: str,
|
||||||
|
quality: str = "1080p",
|
||||||
|
cookies_path: Optional[str] = None,
|
||||||
|
progress_callback: Optional[Callable] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Scarica video (con audio) da YouTube/TikTok/Instagram/Facebook/etc.
|
||||||
|
|
||||||
|
quality: "best" | "1080p" | "720p" | "480p"
|
||||||
|
Per Instagram/Facebook privati servono cookies validi.
|
||||||
|
"""
|
||||||
|
global _current_process
|
||||||
|
reset_stop()
|
||||||
|
out_path = Path(output_dir)
|
||||||
|
out_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
done_file = out_path / ".downloaded_videos"
|
||||||
|
done_set = _load_done_set(done_file)
|
||||||
|
ytdlp = find_ytdlp()
|
||||||
|
|
||||||
|
probe_cmd = [
|
||||||
|
ytdlp, "--dump-json", "--flat-playlist", "--no-download", "--no-warnings", url,
|
||||||
|
]
|
||||||
|
if cookies_path and Path(cookies_path).exists():
|
||||||
|
probe_cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ValueError(result.stderr.strip())
|
||||||
|
except Exception as e:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 0, url, f"error: {e}", 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for line in result.stdout.strip().splitlines():
|
||||||
|
if line.strip():
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 0, url, "error: Nessun contenuto trovato", 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(entries)
|
||||||
|
fmt = _video_format(quality)
|
||||||
|
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
if is_stopped():
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, "", "stopped", 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
title = entry.get("title", f"Video {i + 1}")
|
||||||
|
entry_url = entry.get("url") or entry.get("webpage_url") or entry.get("original_url") or url
|
||||||
|
if total == 1:
|
||||||
|
entry_url = url
|
||||||
|
|
||||||
|
track_key = entry.get("id") or title
|
||||||
|
if track_key in done_set:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, title, "skipped", 100)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, title, "downloading", 0)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
ytdlp,
|
||||||
|
"-f", fmt,
|
||||||
|
"--merge-output-format", "mp4",
|
||||||
|
"--add-metadata",
|
||||||
|
"--no-check-certificates",
|
||||||
|
"--newline",
|
||||||
|
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
|
||||||
|
entry_url,
|
||||||
|
]
|
||||||
|
ffmpeg_dir = find_ffmpeg_dir()
|
||||||
|
if ffmpeg_dir:
|
||||||
|
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||||
|
if cookies_path and Path(cookies_path).exists():
|
||||||
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _process_lock:
|
||||||
|
_current_process = subprocess.Popen(
|
||||||
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in _current_process.stdout:
|
||||||
|
if is_stopped():
|
||||||
|
_current_process.terminate()
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, title, "stopped", 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||||
|
if pct_match and progress_callback:
|
||||||
|
pct = int(float(pct_match.group(1)))
|
||||||
|
progress_callback(i, total, title, "downloading", pct)
|
||||||
|
|
||||||
|
return_code = _current_process.wait()
|
||||||
|
|
||||||
|
with _process_lock:
|
||||||
|
_current_process = None
|
||||||
|
|
||||||
|
if return_code == 0:
|
||||||
|
_mark_done(done_file, track_key)
|
||||||
|
done_set.add(track_key)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, title, "done", 100)
|
||||||
|
else:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, title, f"error: yt-dlp exit {return_code}", 0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
with _process_lock:
|
||||||
|
_current_process = None
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, title, f"error: {e}", 0)
|
||||||
|
|
||||||
|
if progress_callback and not is_stopped():
|
||||||
|
progress_callback(total, total, "", "completed", 100)
|
||||||
@@ -26,6 +26,10 @@
|
|||||||
--purple-hover: #C084FC;
|
--purple-hover: #C084FC;
|
||||||
--purple-dim: #2D1B4D;
|
--purple-dim: #2D1B4D;
|
||||||
|
|
||||||
|
--pink: #EC4899;
|
||||||
|
--pink-hover: #F472B6;
|
||||||
|
--pink-dim: #831843;
|
||||||
|
|
||||||
--red: #E22134;
|
--red: #E22134;
|
||||||
--red-hover: #FF4055;
|
--red-hover: #FF4055;
|
||||||
|
|
||||||
@@ -225,6 +229,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
background: linear-gradient(135deg, #5D2C9C 0%, #2D1B4D 60%, #1A0F2E 100%);
|
background: linear-gradient(135deg, #5D2C9C 0%, #2D1B4D 60%, #1A0F2E 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-pink {
|
||||||
|
background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
||||||
|
|
||||||
.hero-eyebrow {
|
.hero-eyebrow {
|
||||||
@@ -237,6 +245,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
|
|
||||||
.hero-eyebrow.blue { color: var(--blue); }
|
.hero-eyebrow.blue { color: var(--blue); }
|
||||||
.hero-eyebrow.purple { color: var(--purple); }
|
.hero-eyebrow.purple { color: var(--purple); }
|
||||||
|
.hero-eyebrow.pink { color: var(--pink-hover); }
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
@@ -449,6 +458,11 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-pink {
|
||||||
|
background: var(--pink);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
background: #00CEC9;
|
background: #00CEC9;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -512,6 +526,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
|
|
||||||
.percent.green { color: var(--green); }
|
.percent.green { color: var(--green); }
|
||||||
.percent.blue { color: var(--blue); }
|
.percent.blue { color: var(--blue); }
|
||||||
|
.percent.pink { color: var(--pink-hover); }
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -536,6 +551,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
background: linear-gradient(90deg, var(--blue), var(--blue-hover));
|
background: linear-gradient(90deg, var(--blue), var(--blue-hover));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-fill.pink {
|
||||||
|
background: linear-gradient(90deg, var(--pink), var(--pink-hover));
|
||||||
|
}
|
||||||
|
|
||||||
/* ===============================
|
/* ===============================
|
||||||
LOG BOX
|
LOG BOX
|
||||||
=============================== */
|
=============================== */
|
||||||
@@ -559,6 +578,30 @@ 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); }
|
||||||
|
|
||||||
|
/* ===============================
|
||||||
|
HINT BOX
|
||||||
|
=============================== */
|
||||||
|
.hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(236, 72, 153, 0.08);
|
||||||
|
border: 1px solid rgba(236, 72, 153, 0.2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-ico {
|
||||||
|
color: var(--pink-hover);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint strong { color: var(--text); font-weight: 700; }
|
||||||
|
|
||||||
/* ===============================
|
/* ===============================
|
||||||
CHECKBOX
|
CHECKBOX
|
||||||
=============================== */
|
=============================== */
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
<span class="nav-icon">⬇</span>
|
<span class="nav-icon">⬇</span>
|
||||||
<span>Scarica</span>
|
<span>Scarica</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-view="video">
|
||||||
|
<span class="nav-icon">🎬</span>
|
||||||
|
<span>Video</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item" data-view="upgrade">
|
<button class="nav-item" data-view="upgrade">
|
||||||
<span class="nav-icon">⚡</span>
|
<span class="nav-icon">⚡</span>
|
||||||
<span>Upgrade</span>
|
<span>Upgrade</span>
|
||||||
@@ -105,6 +109,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== VIEW: VIDEO ===== -->
|
||||||
|
<section class="view" id="view-video">
|
||||||
|
<header class="hero hero-pink">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-eyebrow pink">VIDEO</div>
|
||||||
|
<h1 class="hero-title">Scarica video</h1>
|
||||||
|
<p class="hero-subtitle">YouTube · TikTok · Instagram · Facebook · qualsiasi link supportato</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-deco">🎬</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2 class="section-label">Sorgente</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">URL</label>
|
||||||
|
<input type="text" id="videoUrlInput" class="input pill" placeholder="https://youtube.com/watch?v=… · https://tiktok.com/… · https://instagram.com/reel/…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Cartella output</label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="path-display" id="videoPathDisplay">Nessuna cartella selezionata</div>
|
||||||
|
<button class="btn btn-ghost pill btn-sm" id="videoBrowseBtn">Sfoglia</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field inline">
|
||||||
|
<label class="field-label">Qualità</label>
|
||||||
|
<select id="videoQuality" class="input pill input-sm">
|
||||||
|
<option value="best">Migliore disponibile</option>
|
||||||
|
<option value="1080p" selected>1080p</option>
|
||||||
|
<option value="720p">720p</option>
|
||||||
|
<option value="480p">480p</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint">
|
||||||
|
<span class="hint-ico">ⓘ</span>
|
||||||
|
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card actions">
|
||||||
|
<button class="btn btn-pink pill btn-lg" id="videoDownloadBtn">
|
||||||
|
<span class="ico">▶</span> Avvia Download
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger pill btn-lg" id="stopVideoBtn" disabled>
|
||||||
|
<span class="ico">◼</span> Stop
|
||||||
|
</button>
|
||||||
|
<div class="counter" id="videoCounter"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="progress-head">
|
||||||
|
<span class="micro-label">Progresso</span>
|
||||||
|
<span class="percent pink" id="videoPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-fill pink" id="videoProgressFill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">Log</h2>
|
||||||
|
<div class="card log-card">
|
||||||
|
<div class="log" id="videoLog"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ===== VIEW: UPGRADE ===== -->
|
<!-- ===== VIEW: UPGRADE ===== -->
|
||||||
<section class="view" id="view-upgrade">
|
<section class="view" id="view-upgrade">
|
||||||
<header class="hero hero-blue">
|
<header class="hero hero-blue">
|
||||||
|
|||||||
+112
-17
@@ -7,12 +7,14 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
|||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
config: {},
|
config: {},
|
||||||
urlList: [],
|
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
||||||
dlOutputDir: "",
|
dlOutputDir: "",
|
||||||
upDir: "",
|
upDir: "",
|
||||||
|
videoOutputDir: "",
|
||||||
showSecret: false,
|
showSecret: false,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
upgrading: false,
|
upgrading: false,
|
||||||
|
videoDownloading: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wait for pywebview ready
|
// Wait for pywebview ready
|
||||||
@@ -53,7 +55,7 @@ const bridgeHandlers = {
|
|||||||
|
|
||||||
"download:done": () => {
|
"download:done": () => {
|
||||||
state.downloading = false;
|
state.downloading = false;
|
||||||
state.urlList = [];
|
state.loaded = null;
|
||||||
$("#urlListBadge").hidden = true;
|
$("#urlListBadge").hidden = true;
|
||||||
$("#urlListBadge").textContent = "";
|
$("#urlListBadge").textContent = "";
|
||||||
$("#downloadBtn").disabled = false;
|
$("#downloadBtn").disabled = false;
|
||||||
@@ -80,12 +82,37 @@ const bridgeHandlers = {
|
|||||||
$("#upgradeBtn").disabled = false;
|
$("#upgradeBtn").disabled = false;
|
||||||
$("#stopUpgradeBtn").disabled = true;
|
$("#stopUpgradeBtn").disabled = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"video:progress": (p) => {
|
||||||
|
if (typeof p.overall === "number") {
|
||||||
|
const pct = Math.round(p.overall * 100);
|
||||||
|
$("#videoProgressFill").style.width = pct + "%";
|
||||||
|
$("#videoPercent").textContent = pct + "%";
|
||||||
|
}
|
||||||
|
if (p.status === "downloading" && typeof p.idx === "number") {
|
||||||
|
const prefix = p.url_total > 1 ? `[${(p.url_idx ?? 0) + 1}/${p.url_total}] ` : "";
|
||||||
|
$("#videoCounter").textContent = `${prefix}Video ${p.idx + 1} di ${p.total}`;
|
||||||
|
}
|
||||||
|
if (p.status === "completed" && p.url_total > 1) {
|
||||||
|
$("#videoCounter").textContent = `Completato: ${p.url_total} URL`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"video:done": () => {
|
||||||
|
state.videoDownloading = false;
|
||||||
|
$("#videoDownloadBtn").disabled = false;
|
||||||
|
$("#stopVideoBtn").disabled = true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Logging
|
// Logging
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const logEls = { download: () => $("#dlLog"), upgrade: () => $("#upLog") };
|
const logEls = {
|
||||||
|
download: () => $("#dlLog"),
|
||||||
|
upgrade: () => $("#upLog"),
|
||||||
|
video: () => $("#videoLog"),
|
||||||
|
};
|
||||||
|
|
||||||
function classifyLog(msg) {
|
function classifyLog(msg) {
|
||||||
if (msg.startsWith("[ERRORE]")) return "l-err";
|
if (msg.startsWith("[ERRORE]")) return "l-err";
|
||||||
@@ -159,6 +186,13 @@ async function init() {
|
|||||||
$("#dlPathDisplay").classList.remove("empty");
|
$("#dlPathDisplay").classList.remove("empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Video tab — usa output_dir come default
|
||||||
|
state.videoOutputDir = state.config.output_dir || "";
|
||||||
|
if (state.videoOutputDir) {
|
||||||
|
$("#videoPathDisplay").textContent = state.videoOutputDir;
|
||||||
|
$("#videoPathDisplay").classList.remove("empty");
|
||||||
|
}
|
||||||
|
|
||||||
// Upgrade tab — default threshold
|
// Upgrade tab — default threshold
|
||||||
$("#upThreshold").value = state.config.hq_threshold || 310;
|
$("#upThreshold").value = state.config.hq_threshold || 310;
|
||||||
}
|
}
|
||||||
@@ -187,27 +221,23 @@ $("#loadListBtn").addEventListener("click", async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.count) {
|
if (!res.count) {
|
||||||
toast("Il file non contiene URL validi", "error");
|
toast("Il file non contiene voci valide", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.urlList = res.urls;
|
state.loaded = res;
|
||||||
$("#urlInput").value = path;
|
$("#urlInput").value = path;
|
||||||
const badge = $("#urlListBadge");
|
const badge = $("#urlListBadge");
|
||||||
badge.textContent = `· ${res.count} URL caricati`;
|
if (res.kind === "tracks") {
|
||||||
badge.hidden = false;
|
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`);
|
appendLog("download", `[INFO] Caricati ${res.count} URL dal file`);
|
||||||
|
}
|
||||||
|
badge.hidden = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#downloadBtn").addEventListener("click", async () => {
|
$("#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) {
|
if (!state.dlOutputDir) {
|
||||||
toast("Seleziona una cartella di destinazione", "error");
|
toast("Seleziona una cartella di destinazione", "error");
|
||||||
return;
|
return;
|
||||||
@@ -220,10 +250,30 @@ $("#downloadBtn").addEventListener("click", async () => {
|
|||||||
$("#dlProgressFill").style.width = "0%";
|
$("#dlProgressFill").style.width = "0%";
|
||||||
$("#dlPercent").textContent = "0%";
|
$("#dlPercent").textContent = "0%";
|
||||||
|
|
||||||
const res = await window.pywebview.api.start_download({
|
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,
|
urls,
|
||||||
output_dir: state.dlOutputDir,
|
output_dir: state.dlOutputDir,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast(res.error || "Errore", "error");
|
toast(res.error || "Errore", "error");
|
||||||
bridgeHandlers["download:done"]();
|
bridgeHandlers["download:done"]();
|
||||||
@@ -235,6 +285,51 @@ $("#stopDownloadBtn").addEventListener("click", async () => {
|
|||||||
$("#stopDownloadBtn").disabled = true;
|
$("#stopDownloadBtn").disabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// VIDEO tab
|
||||||
|
// ============================================================
|
||||||
|
$("#videoBrowseBtn").addEventListener("click", async () => {
|
||||||
|
const path = await window.pywebview.api.browse_directory();
|
||||||
|
if (path) {
|
||||||
|
state.videoOutputDir = path;
|
||||||
|
$("#videoPathDisplay").textContent = path;
|
||||||
|
$("#videoPathDisplay").classList.remove("empty");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#videoDownloadBtn").addEventListener("click", async () => {
|
||||||
|
const url = $("#videoUrlInput").value.trim();
|
||||||
|
if (!url) {
|
||||||
|
toast("Inserisci un URL", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.videoOutputDir) {
|
||||||
|
toast("Seleziona una cartella di destinazione", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.videoDownloading = true;
|
||||||
|
$("#videoDownloadBtn").disabled = true;
|
||||||
|
$("#stopVideoBtn").disabled = false;
|
||||||
|
$("#videoProgressFill").style.width = "0%";
|
||||||
|
$("#videoPercent").textContent = "0%";
|
||||||
|
|
||||||
|
const res = await window.pywebview.api.start_video_download({
|
||||||
|
urls: [url],
|
||||||
|
output_dir: state.videoOutputDir,
|
||||||
|
quality: $("#videoQuality").value,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
toast(res.error || "Errore", "error");
|
||||||
|
bridgeHandlers["video:done"]();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#stopVideoBtn").addEventListener("click", async () => {
|
||||||
|
await window.pywebview.api.stop_video_download();
|
||||||
|
$("#stopVideoBtn").disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// UPGRADE tab
|
// UPGRADE tab
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user