Initial commit: MusicDownload con UI pywebview
- Frontend HTML/CSS/JS in webui/ (stile Spotify-like, hero gradient, modal blur) - API bridge JS<->Python in api/bridge.py - Core invariato: downloader yt-dlp, upgrader, spotify client, config - Build scripts macOS (WebKit) e Windows (EdgeChromium) - .gitignore esclude credenziali (config.json, cookies.txt) e artefatti build Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
commit
8c06a26ddb
25 files changed
+4316
No files matched your search
@@ -0,0 +1,59 @@
|
||||
name: Build MusicDownload
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Lancia manualmente da GitHub
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # Builda automaticamente sui tag (es. v1.0.0)
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: brew install ffmpeg
|
||||
|
||||
- name: Build app
|
||||
run: python build_macos.py
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-macOS
|
||||
path: dist/MusicDownload.app
|
||||
compression-level: 9
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Build app
|
||||
run: python build_windows.py
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-Windows
|
||||
path: dist/MusicDownload
|
||||
compression-level: 9
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# PyInstaller
|
||||
build/
|
||||
dist/
|
||||
*.spec
|
||||
bundle_bin/
|
||||
|
||||
# App data — sensibili o specifici dell'utente
|
||||
config.json
|
||||
cookies.txt
|
||||
MUSICA/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
.downloaded_tracks
|
||||
.upgraded_tracks
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
Apri con claude-social.command
|
||||
@@ -0,0 +1,7 @@
|
||||
https://open.spotify.com/playlist/0Q4JvzeT0q3FLsj5fqffMV?si=d17c4002f6f349c4
|
||||
https://open.spotify.com/playlist/4FHKFYziOZF75AkT3aR7QP?si=b013d1cc79ff47a3
|
||||
https://open.spotify.com/playlist/0mpX2EakDLmFjifuh1fQKU?si=bab58f6a7e984560
|
||||
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
|
||||
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
|
||||
https://open.spotify.com/playlist/4MmZgwwHgrSs2astI4NVxI?si=021eef1a758a42fc
|
||||
https://open.spotify.com/playlist/4OAx11L58sozuyNVTBi6r4?si=27d4be41e53c4e8a
|
||||
@@ -0,0 +1,18 @@
|
||||
https://open.spotify.com/playlist/5HVNSaqs3HgzIsaCVeVGrI?si=dd86ade90d2347ae
|
||||
https://open.spotify.com/playlist/7ptoSkENfnO1yEQYrly725?si=4d694c3390a54ce6
|
||||
https://open.spotify.com/playlist/6VjXyFH9Z5HlGPAjRBKR32?si=e8fc98ee593e4e69
|
||||
https://open.spotify.com/playlist/4rdb0iJ7hZhqgBFpXHGVU6?si=96a6838af2a3487f
|
||||
https://open.spotify.com/playlist/51ireDdqSrt6juqBxkMsrm?si=6c06fc88e7354b50
|
||||
https://open.spotify.com/playlist/50SNkybvZnubt5uK4F2kgk?si=b7a146e4d8a54d35
|
||||
https://open.spotify.com/playlist/0erLbMzXD0U9xJUPlOeKrX?si=54c87940c7be4c65
|
||||
https://open.spotify.com/playlist/3FGGBSXjLGU5kfPCxL9K6f?si=77e1eee04a0745aa
|
||||
https://open.spotify.com/playlist/2OxJrcgA6AQGk1sSQ8ZZ2a?si=98ede53357124322
|
||||
https://open.spotify.com/playlist/345eEKyGNyluygzMsS1n1H?si=9d4be71b28e94339
|
||||
https://open.spotify.com/playlist/0Umelkk6D75eZHZuxkTtLF?si=7c2f1ff3d2354abb
|
||||
https://open.spotify.com/playlist/2k9QmVpMHJfyjVVvJdnRLb?si=765f62a3caf843ef
|
||||
https://open.spotify.com/playlist/7xVYvU65ezCBiOu49BSV4I?si=608741585f91483d
|
||||
https://open.spotify.com/playlist/1AOMaSEZf0aChEOK6L5uI3?si=e44863bf2782419b
|
||||
https://open.spotify.com/playlist/3Zn4AOuMAKFCJD4VLUD0uY?si=2179f9cb5e5b4092
|
||||
https://open.spotify.com/playlist/1LA5WDfX5Z4I0fQSIx4L8N?si=9918a8eadb5744b9
|
||||
https://open.spotify.com/playlist/0NHDUXVEsoFMwzkrYaRHG0?si=58e61fe6319e48cb
|
||||
https://open.spotify.com/playlist/2Y6PQB7k73HCrBFwiBy2Jf?si=8da4990576cd444f
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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.
|
||||
|
||||
Sviluppata da **LuZa**.
|
||||
|
||||
## Funzionalita
|
||||
|
||||
- **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
|
||||
|
||||
## 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
|
||||
|
||||
## Installazione
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Credenziali Spotify API
|
||||
|
||||
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**
|
||||
5. Copia **Client ID** e **Client Secret** nelle Impostazioni dell'app
|
||||
|
||||
Le credenziali sono gratuite e non richiedono Spotify Premium.
|
||||
|
||||
## Struttura progetto
|
||||
|
||||
```
|
||||
MusicDownload/
|
||||
├── main.py # Entry point
|
||||
├── requirements.txt # Dipendenze Python
|
||||
├── build_macos.py # Script build macOS (.app)
|
||||
├── build_windows.py # Script build Windows (.exe)
|
||||
│
|
||||
├── core/ # Logica backend
|
||||
│ ├── 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
|
||||
│
|
||||
└── gui/ # Interfaccia grafica
|
||||
├── app.py # Finestra principale con sidebar
|
||||
├── download_tab.py # Pagina download
|
||||
├── upgrade_tab.py # Pagina upgrade
|
||||
└── settings_tab.py # Pagina impostazioni
|
||||
```
|
||||
|
||||
## Configurazione
|
||||
|
||||
Il file `config.json` viene creato automaticamente:
|
||||
|
||||
| Chiave | Default | Descrizione |
|
||||
|---|---|---|
|
||||
| `client_id` | `""` | Spotify Client ID |
|
||||
| `client_secret` | `""` | Spotify Client Secret |
|
||||
| `bitrate` | `"320K"` | Bitrate download (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) |
|
||||
|
||||
Posizione del file:
|
||||
- **Sviluppo**: directory del progetto
|
||||
- **macOS bundle**: `~/Library/Application Support/MusicDownload/`
|
||||
- **Windows bundle**: `%APPDATA%/MusicDownload/`
|
||||
|
||||
## Build
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
pip install pyinstaller
|
||||
python3 build_macos.py
|
||||
```
|
||||
|
||||
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg e ffprobe inclusi.
|
||||
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
pip install pyinstaller
|
||||
python build_windows.py
|
||||
```
|
||||
|
||||
Produce `dist/MusicDownload/MusicDownload.exe` con tutti i binari inclusi.
|
||||
|
||||
## 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`.
|
||||
|
||||
## Licenza
|
||||
|
||||
Uso personale.
|
||||
@@ -0,0 +1,5 @@
|
||||
https://open.spotify.com/intl-it/album/3lgCqbQpmLscSRuWVMT5wS?si=UPiqK8smRgO_1JAAiZTrYg
|
||||
https://open.spotify.com/intl-it/album/3pGb3m5KA1Y3gCVT6leCL3?si=Cj_OrTE5QAiEnwMD63EunA
|
||||
https://open.spotify.com/intl-it/album/2ewhdpODXqoB0fXIKfq4u2?si=e2N7jWHiTWGiudHHMJNHHQ
|
||||
https://open.spotify.com/intl-it/album/2xGfPfmx6faZUr9IaKEiSG?si=2Q-qMwWCTp-Yp7RIfhE2Ig
|
||||
https://open.spotify.com/intl-it/album/43pKffJweYUVwEYcHPIfox?si=2E8IoUmOQp-cgsCujVBPMQ
|
||||
Whitespace-only changes.
+390
@@ -0,0 +1,390 @@
|
||||
"""Ponte JS <-> Python: tutti i metodi che la UI invoca via pywebview.api."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.config import load_config, save_config, VERSION
|
||||
from core.downloader import (
|
||||
download_playlist,
|
||||
download_direct_url,
|
||||
request_stop as request_download_stop,
|
||||
reset_stop as reset_download_stop,
|
||||
is_stopped as download_is_stopped,
|
||||
)
|
||||
from core.spotify_client import get_access_token, resolve_spotify_url
|
||||
from core.upgrader import (
|
||||
upgrade_folder,
|
||||
request_stop as request_upgrade_stop,
|
||||
count_files_info,
|
||||
)
|
||||
|
||||
|
||||
SPOTIFY_GUIDE_TEXT = """\
|
||||
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
|
||||
2) Clicca "Create app".
|
||||
3) Compila:
|
||||
- App name: MusicDownload (o quello che vuoi)
|
||||
- Description: a piacere
|
||||
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
|
||||
- Seleziona "Web API"
|
||||
- Accetta i termini, "Save"
|
||||
4) Apri la app appena creata > "Settings" e copia:
|
||||
- Client ID
|
||||
- Client Secret (clicca "View client secret")
|
||||
5) Incollali qui sotto e premi "Salva".
|
||||
|
||||
Note: credenziali gratuite, no Premium. Limite 100 richieste/minuto.
|
||||
"""
|
||||
|
||||
|
||||
def _js_safe(value: Any) -> str:
|
||||
"""Serializza un valore per inserirlo in evaluate_js."""
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
class Api:
|
||||
"""Espone i metodi a JavaScript tramite pywebview js_api."""
|
||||
|
||||
def __init__(self):
|
||||
self.window = None # impostato dopo create_window
|
||||
self._download_thread: Optional[threading.Thread] = None
|
||||
self._upgrade_thread: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _emit(self, channel: str, payload: dict | str | None = None) -> None:
|
||||
"""Invia un evento al frontend."""
|
||||
if not self.window:
|
||||
return
|
||||
try:
|
||||
self.window.evaluate_js(
|
||||
f"window.bridge && window.bridge.emit({_js_safe(channel)}, {_js_safe(payload)});"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _log(self, view: str, msg: str) -> None:
|
||||
self._emit("log", {"view": view, "msg": msg})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bootstrap
|
||||
# ------------------------------------------------------------------
|
||||
def get_init_data(self) -> dict:
|
||||
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
|
||||
cfg = load_config()
|
||||
return {
|
||||
"version": VERSION,
|
||||
"config": cfg,
|
||||
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings
|
||||
# ------------------------------------------------------------------
|
||||
def save_settings(self, payload: dict) -> dict:
|
||||
try:
|
||||
threshold = int(payload.get("hq_threshold", 310))
|
||||
except (TypeError, ValueError):
|
||||
threshold = 310
|
||||
|
||||
config = {
|
||||
"client_id": (payload.get("client_id") or "").strip(),
|
||||
"client_secret": (payload.get("client_secret") or "").strip(),
|
||||
"bitrate": payload.get("bitrate", "320K"),
|
||||
"hq_threshold": threshold,
|
||||
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
||||
"output_dir": (payload.get("output_dir") or "").strip(),
|
||||
"theme": payload.get("theme", "dark"),
|
||||
}
|
||||
save_config(config)
|
||||
return {"ok": True}
|
||||
|
||||
def check_update(self) -> dict:
|
||||
"""Controlla aggiornamenti chiamando version.json remoto."""
|
||||
import urllib.request
|
||||
|
||||
UPDATE_URL = "https://www.djluza.com/musicdownload/version.json"
|
||||
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", "")
|
||||
return {
|
||||
"ok": True,
|
||||
"current": VERSION,
|
||||
"remote": remote,
|
||||
"is_new": bool(remote and remote != VERSION),
|
||||
"download_url": data.get("download_url", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "current": VERSION}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File dialogs (delegati a pywebview)
|
||||
# ------------------------------------------------------------------
|
||||
def _folder_dialog_type(self):
|
||||
import webview
|
||||
# API nuova (>= 5.x): webview.FileDialog.FOLDER
|
||||
# API vecchia: webview.FOLDER_DIALOG
|
||||
if hasattr(webview, "FileDialog"):
|
||||
return webview.FileDialog.FOLDER
|
||||
return webview.FOLDER_DIALOG
|
||||
|
||||
def _open_dialog_type(self):
|
||||
import webview
|
||||
if hasattr(webview, "FileDialog"):
|
||||
return webview.FileDialog.OPEN
|
||||
return webview.OPEN_DIALOG
|
||||
|
||||
def browse_directory(self) -> str:
|
||||
if not self.window:
|
||||
return ""
|
||||
result = self.window.create_file_dialog(self._folder_dialog_type())
|
||||
if result:
|
||||
return result[0] if isinstance(result, (list, tuple)) else result
|
||||
return ""
|
||||
|
||||
def browse_file(self, filetypes: Optional[list] = None) -> str:
|
||||
if not self.window:
|
||||
return ""
|
||||
if filetypes:
|
||||
ft = tuple(filetypes)
|
||||
else:
|
||||
ft = ("Text files (*.txt)", "All files (*.*)")
|
||||
result = self.window.create_file_dialog(self._open_dialog_type(), file_types=ft)
|
||||
if result:
|
||||
return result[0] if isinstance(result, (list, tuple)) else result
|
||||
return ""
|
||||
|
||||
def load_url_list(self, path: str) -> dict:
|
||||
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)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def open_external_url(self, url: str) -> None:
|
||||
webbrowser.open(url)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Audio folder scan (Upgrade tab)
|
||||
# ------------------------------------------------------------------
|
||||
def scan_audio_folder(self, directory: str, recursive: bool) -> dict:
|
||||
try:
|
||||
total, done = count_files_info(directory, bool(recursive))
|
||||
return {"ok": True, "total": total, "done": done, "remaining": total - done}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DOWNLOAD
|
||||
# ------------------------------------------------------------------
|
||||
def start_download(self, payload: dict) -> dict:
|
||||
if self._download_thread and self._download_thread.is_alive():
|
||||
return {"ok": False, "error": "Download gia in corso"}
|
||||
|
||||
urls = payload.get("urls") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessun URL fornito"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._download_worker,
|
||||
args=(list(urls), 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 _download_worker(self, urls: list, output_dir: str) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
total_urls = len(urls)
|
||||
multi = total_urls > 1
|
||||
view = "download"
|
||||
|
||||
token = None
|
||||
has_spotify = any("spotify.com" in u for u in urls)
|
||||
if has_spotify:
|
||||
client_id = cfg.get("client_id", "")
|
||||
client_secret = cfg.get("client_secret", "")
|
||||
if not client_id or not client_secret:
|
||||
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
|
||||
self._emit("download:done", {"ok": False})
|
||||
return
|
||||
self._log(view, "[INFO] Autenticazione Spotify...")
|
||||
try:
|
||||
token = get_access_token(client_id, client_secret)
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
|
||||
self._emit("download:done", {"ok": False})
|
||||
return
|
||||
|
||||
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}")
|
||||
|
||||
is_spotify = "spotify.com" in url
|
||||
|
||||
_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 == "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(_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("download:progress", payload_evt)
|
||||
|
||||
if is_spotify:
|
||||
self._log(view, "[INFO] Recupero informazioni da Spotify...")
|
||||
try:
|
||||
label, name, tracks = resolve_spotify_url(token, url)
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
|
||||
dest_dir = os.path.join(output_dir, name)
|
||||
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
|
||||
else:
|
||||
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
|
||||
download_direct_url(url, output_dir, bitrate, cookies_path, progress_cb)
|
||||
|
||||
if multi and not download_is_stopped():
|
||||
self._log(view, f"\n[INFO] Completate tutte le {total_urls} playlist/URL!")
|
||||
self._emit("download:progress", {
|
||||
"status": "completed", "overall": 1.0,
|
||||
"url_total": total_urls,
|
||||
})
|
||||
|
||||
self._emit("download:done", {"ok": True})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UPGRADE
|
||||
# ------------------------------------------------------------------
|
||||
def start_upgrade(self, payload: dict) -> dict:
|
||||
if self._upgrade_thread and self._upgrade_thread.is_alive():
|
||||
return {"ok": False, "error": "Upgrade gia in corso"}
|
||||
|
||||
directory = (payload.get("directory") or "").strip()
|
||||
recursive = bool(payload.get("recursive", False))
|
||||
try:
|
||||
threshold = int(payload.get("threshold", 310))
|
||||
except (TypeError, ValueError):
|
||||
threshold = 310
|
||||
|
||||
if not directory:
|
||||
return {"ok": False, "error": "Cartella non impostata"}
|
||||
|
||||
cfg = load_config()
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
|
||||
self._upgrade_thread = threading.Thread(
|
||||
target=self._upgrade_worker,
|
||||
args=(directory, threshold, cookies_path, recursive),
|
||||
daemon=True,
|
||||
)
|
||||
self._upgrade_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def stop_upgrade(self) -> dict:
|
||||
request_upgrade_stop()
|
||||
self._log("upgrade", "[INFO] Interruzione richiesta...")
|
||||
return {"ok": True}
|
||||
|
||||
def _upgrade_worker(self, directory, threshold, cookies_path, recursive):
|
||||
view = "upgrade"
|
||||
|
||||
def progress_cb(idx, total, filename, status, old_kbps, new_kbps):
|
||||
payload_evt = {
|
||||
"idx": idx, "total": total, "filename": filename,
|
||||
"status": status, "old_kbps": old_kbps, "new_kbps": new_kbps,
|
||||
}
|
||||
if total > 0:
|
||||
payload_evt["overall"] = min(idx / total, 1.0)
|
||||
|
||||
if status == "searching":
|
||||
self._log(view, f"[CERCA] {filename} ({old_kbps}kbps)")
|
||||
elif status == "not_found":
|
||||
self._log(view, f"[NON TROVATO] {filename}")
|
||||
elif status == "cover_only":
|
||||
self._log(view, f"[COPERTINA] {filename} (gia {old_kbps}kbps)")
|
||||
elif status == "cover_done":
|
||||
self._log(view, f"[OK COPERTINA] {filename}")
|
||||
elif status == "cover_failed":
|
||||
self._log(view, f"[SKIP] {filename} - copertina non disponibile ({old_kbps}kbps ok)")
|
||||
elif status == "downloading":
|
||||
self._log(view, f"[DOWNLOAD] {filename} ({old_kbps}kbps)")
|
||||
elif status == "upgraded":
|
||||
diff = new_kbps - old_kbps
|
||||
diff_str = f" (+{diff}kbps)" if diff > 0 else ""
|
||||
self._log(view, f"[UPGRADE] {filename}: {old_kbps} -> {new_kbps}kbps{diff_str}")
|
||||
elif status == "download_error":
|
||||
self._log(view, f"[ERRORE] {filename}: download fallito")
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Upgrade interrotto.")
|
||||
elif status == "completed":
|
||||
payload_evt["overall"] = 1.0
|
||||
self._log(view, "[INFO] Upgrade completato!")
|
||||
|
||||
self._emit("upgrade:progress", payload_evt)
|
||||
|
||||
upgrade_folder(directory, threshold, cookies_path, recursive, progress_cb)
|
||||
self._emit("upgrade:done", {"ok": True})
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build script per creare MusicDownload.app su macOS.
|
||||
|
||||
Uso:
|
||||
python3 build_macos.py
|
||||
|
||||
Cosa fa:
|
||||
1. Scarica il binario standalone di yt-dlp per macOS
|
||||
2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew
|
||||
3. Esegue PyInstaller per creare MusicDownload.app
|
||||
|
||||
Requisiti:
|
||||
pip3 install pyinstaller
|
||||
brew install ffmpeg
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
BUILD_DIR = ROOT / "build"
|
||||
DIST_DIR = ROOT / "dist"
|
||||
BUNDLE_DIR = ROOT / "bundle_bin" # binari da includere nell'app
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"\n>>> {msg}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Scarica yt-dlp standalone
|
||||
# =========================================================================
|
||||
def download_ytdlp():
|
||||
"""Scarica il binario standalone di yt-dlp per macOS."""
|
||||
arch = platform.machine() # arm64 o x86_64
|
||||
if arch == "arm64":
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||
else:
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos_legacy"
|
||||
|
||||
dest = BUNDLE_DIR / "yt-dlp"
|
||||
if dest.exists():
|
||||
log(f"yt-dlp gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
log(f"Scarico yt-dlp da {url} ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Usa curl perche urllib di Python 3.8 ha problemi SSL su macOS
|
||||
subprocess.run(
|
||||
["curl", "-L", "-o", str(dest), url],
|
||||
check=True,
|
||||
)
|
||||
dest.chmod(dest.stat().st_mode | stat.S_IEXEC)
|
||||
log(f"yt-dlp scaricato: {dest}")
|
||||
return dest
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Raccogli ffmpeg/ffprobe + dylib
|
||||
# =========================================================================
|
||||
def find_brew_binary(name):
|
||||
"""Trova il percorso reale di un binario brew."""
|
||||
path = shutil.which(name, path="/opt/homebrew/bin:/usr/local/bin")
|
||||
if not path:
|
||||
path = shutil.which(name)
|
||||
if not path:
|
||||
print(f"ERRORE: {name} non trovato. Installa con: brew install {name}")
|
||||
sys.exit(1)
|
||||
return str(Path(path).resolve())
|
||||
|
||||
|
||||
def collect_dylibs(binary_path, collected=None):
|
||||
"""Raccoglie ricorsivamente tutte le dylib linkate da un binario."""
|
||||
if collected is None:
|
||||
collected = set()
|
||||
|
||||
result = subprocess.run(
|
||||
["otool", "-L", binary_path],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
for line in result.stdout.splitlines()[1:]: # skip prima riga (nome binario)
|
||||
line = line.strip()
|
||||
match = re.match(r"(.+\.dylib)\s+\(", line)
|
||||
if not match:
|
||||
continue
|
||||
dylib = match.group(1).strip()
|
||||
# Includi solo librerie homebrew (non di sistema)
|
||||
if dylib.startswith("/opt/homebrew/") or dylib.startswith("/usr/local/"):
|
||||
real = str(Path(dylib).resolve())
|
||||
if real not in collected and os.path.exists(real):
|
||||
collected.add(real)
|
||||
# Raccogli ricorsivamente
|
||||
collect_dylibs(real, collected)
|
||||
|
||||
return collected
|
||||
|
||||
|
||||
def bundle_ffmpeg():
|
||||
"""Copia ffmpeg, ffprobe e tutte le loro dylib in bundle_bin/."""
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ffmpeg_path = find_brew_binary("ffmpeg")
|
||||
ffprobe_path = find_brew_binary("ffprobe")
|
||||
|
||||
log("Raccolgo librerie dinamiche di ffmpeg/ffprobe...")
|
||||
all_dylibs = set()
|
||||
collect_dylibs(ffmpeg_path, all_dylibs)
|
||||
collect_dylibs(ffprobe_path, all_dylibs)
|
||||
|
||||
log(f"Trovate {len(all_dylibs)} dylib")
|
||||
|
||||
# Copia binari
|
||||
for src in (ffmpeg_path, ffprobe_path):
|
||||
dst = BUNDLE_DIR / Path(src).name
|
||||
shutil.copy2(src, dst)
|
||||
dst.chmod(dst.stat().st_mode | stat.S_IEXEC)
|
||||
log(f" Copiato: {Path(src).name}")
|
||||
|
||||
# Copia dylib
|
||||
lib_dir = BUNDLE_DIR / "lib"
|
||||
lib_dir.mkdir(exist_ok=True)
|
||||
|
||||
for dylib in sorted(all_dylibs):
|
||||
dst = lib_dir / Path(dylib).name
|
||||
shutil.copy2(dylib, dst)
|
||||
|
||||
log(f" Copiate {len(all_dylibs)} librerie in bundle_bin/lib/")
|
||||
|
||||
# Fix rpath nei binari: cambia i riferimenti /opt/homebrew/... a @executable_path/lib/
|
||||
log("Fix rpath nei binari...")
|
||||
for binary_name in ("ffmpeg", "ffprobe"):
|
||||
binary = BUNDLE_DIR / binary_name
|
||||
_fix_rpaths(binary, all_dylibs)
|
||||
|
||||
# Fix rpath nelle dylib stesse
|
||||
for dylib in sorted(all_dylibs):
|
||||
dst = lib_dir / Path(dylib).name
|
||||
_fix_rpaths(dst, all_dylibs, is_dylib=True)
|
||||
|
||||
log("ffmpeg/ffprobe bundled con successo")
|
||||
|
||||
|
||||
def _fix_rpaths(binary, all_dylibs, is_dylib=False):
|
||||
"""Sostituisce i path assoluti delle dylib con path relativi."""
|
||||
prefix = "@executable_path/lib/" if not is_dylib else "@loader_path/"
|
||||
|
||||
# Se e una dylib, fix anche il suo install name
|
||||
if is_dylib:
|
||||
subprocess.run(
|
||||
["install_name_tool", "-id",
|
||||
f"@loader_path/{binary.name}", str(binary)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
for dylib in all_dylibs:
|
||||
dylib_name = Path(dylib).name
|
||||
subprocess.run(
|
||||
["install_name_tool", "-change",
|
||||
dylib, f"{prefix}{dylib_name}", str(binary)],
|
||||
capture_output=True,
|
||||
)
|
||||
# Prova anche con path non-resolved (symlink)
|
||||
for brew_prefix in ("/opt/homebrew", "/usr/local"):
|
||||
for alt in (f"{brew_prefix}/opt/", f"{brew_prefix}/lib/", f"{brew_prefix}/Cellar/"):
|
||||
result = subprocess.run(
|
||||
["otool", "-L", str(binary)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if dylib_name in line and (alt in line):
|
||||
old_path = re.match(r"(.+\.dylib)", line)
|
||||
if old_path:
|
||||
subprocess.run(
|
||||
["install_name_tool", "-change",
|
||||
old_path.group(1).strip(),
|
||||
f"{prefix}{dylib_name}", str(binary)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. PyInstaller
|
||||
# =========================================================================
|
||||
def run_pyinstaller():
|
||||
"""Esegue PyInstaller per creare l'app."""
|
||||
log("Eseguo PyInstaller...")
|
||||
|
||||
# Genera lista --add-binary per i binari bundled
|
||||
add_binaries = []
|
||||
for f in BUNDLE_DIR.iterdir():
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
add_binaries.extend(["--add-binary", f"{f}:."])
|
||||
# Dylibs in lib/
|
||||
lib_dir = BUNDLE_DIR / "lib"
|
||||
if lib_dir.exists():
|
||||
for f in lib_dir.iterdir():
|
||||
if f.is_file():
|
||||
add_binaries.extend(["--add-binary", f"{f}:lib"])
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "MusicDownload",
|
||||
"--windowed",
|
||||
"--onedir",
|
||||
"--noconfirm",
|
||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||
"--add-data", f"{ROOT / 'webui'}:webui",
|
||||
# Hidden imports per pywebview (WebKit su macOS)
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.cocoa",
|
||||
"--hidden-import", "requests",
|
||||
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
str(ROOT / "main.py"),
|
||||
]
|
||||
|
||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||
|
||||
app_path = DIST_DIR / "MusicDownload.app"
|
||||
if app_path.exists():
|
||||
log(f"Build completata: {app_path}")
|
||||
# Mostra dimensione
|
||||
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
|
||||
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
||||
else:
|
||||
log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller")
|
||||
# Potrebbe essere in dist/MusicDownload/ (non .app)
|
||||
alt = DIST_DIR / "MusicDownload"
|
||||
if alt.exists():
|
||||
log(f"Trovata directory: {alt}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main
|
||||
# =========================================================================
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" MusicDownload — Build macOS")
|
||||
print("=" * 50)
|
||||
|
||||
# Verifica pyinstaller
|
||||
try:
|
||||
import PyInstaller
|
||||
log(f"PyInstaller {PyInstaller.__version__}")
|
||||
except ImportError:
|
||||
log("Installo PyInstaller...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "pyinstaller"],
|
||||
check=True)
|
||||
|
||||
# Pulisci bundle precedente
|
||||
if BUNDLE_DIR.exists():
|
||||
shutil.rmtree(BUNDLE_DIR)
|
||||
|
||||
download_ytdlp()
|
||||
bundle_ffmpeg()
|
||||
run_pyinstaller()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(" DONE!")
|
||||
print("=" * 50)
|
||||
app = DIST_DIR / "MusicDownload.app"
|
||||
if app.exists():
|
||||
print(f"\n L'app e in: {app}")
|
||||
print(f" Per testarla: open '{app}'")
|
||||
print(f"\n Per distribuirla, comprimi la cartella:")
|
||||
print(f" zip -r MusicDownload.zip dist/MusicDownload.app")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Build script per creare MusicDownload.exe su Windows.
|
||||
|
||||
Uso:
|
||||
python build_windows.py
|
||||
|
||||
Cosa fa:
|
||||
1. Scarica il binario standalone di yt-dlp per Windows
|
||||
2. Scarica ffmpeg static build per Windows
|
||||
3. Esegue PyInstaller per creare MusicDownload.exe
|
||||
|
||||
Requisiti:
|
||||
pip install pyinstaller
|
||||
pip install -r requirements.txt
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
BUILD_DIR = ROOT / "build"
|
||||
DIST_DIR = ROOT / "dist"
|
||||
BUNDLE_DIR = ROOT / "bundle_bin"
|
||||
|
||||
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"\n>>> {msg}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Scarica yt-dlp.exe
|
||||
# =========================================================================
|
||||
def download_ytdlp():
|
||||
dest = BUNDLE_DIR / "yt-dlp.exe"
|
||||
if dest.exists():
|
||||
log(f"yt-dlp.exe gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
log(f"Scarico yt-dlp.exe ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
urllib.request.urlretrieve(YTDLP_URL, dest)
|
||||
log(f"yt-dlp.exe scaricato ({dest.stat().st_size // 1024 // 1024} MB)")
|
||||
return dest
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Scarica ffmpeg static build
|
||||
# =========================================================================
|
||||
def download_ffmpeg():
|
||||
ffmpeg_exe = BUNDLE_DIR / "ffmpeg.exe"
|
||||
ffprobe_exe = BUNDLE_DIR / "ffprobe.exe"
|
||||
|
||||
if ffmpeg_exe.exists() and ffprobe_exe.exists():
|
||||
log("ffmpeg.exe e ffprobe.exe gia presenti")
|
||||
return
|
||||
|
||||
log("Scarico ffmpeg static build per Windows ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Scarica lo zip in memoria
|
||||
response = urllib.request.urlopen(FFMPEG_URL)
|
||||
zip_data = io.BytesIO(response.read())
|
||||
log("Download completato, estraggo ffmpeg.exe e ffprobe.exe ...")
|
||||
|
||||
with zipfile.ZipFile(zip_data) as zf:
|
||||
for member in zf.namelist():
|
||||
basename = Path(member).name
|
||||
if basename in ("ffmpeg.exe", "ffprobe.exe"):
|
||||
# Estrai solo il file, senza sottocartelle
|
||||
data = zf.read(member)
|
||||
dest = BUNDLE_DIR / basename
|
||||
dest.write_bytes(data)
|
||||
log(f" Estratto: {basename} ({len(data) // 1024 // 1024} MB)")
|
||||
|
||||
if not ffmpeg_exe.exists():
|
||||
print("ERRORE: ffmpeg.exe non trovato nello zip!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. PyInstaller
|
||||
# =========================================================================
|
||||
def run_pyinstaller():
|
||||
log("Eseguo PyInstaller...")
|
||||
|
||||
# Genera lista --add-binary per i binari bundled
|
||||
add_binaries = []
|
||||
for f in BUNDLE_DIR.iterdir():
|
||||
if f.is_file() and f.suffix in (".exe", ""):
|
||||
add_binaries.extend(["--add-binary", f"{f};."])
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "MusicDownload",
|
||||
"--windowed",
|
||||
"--onedir",
|
||||
"--noconfirm",
|
||||
# Icona (se presente)
|
||||
# "--icon", str(ROOT / "icon.ico"),
|
||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||
"--add-data", f"{ROOT / 'webui'};webui",
|
||||
# Hidden imports per pywebview (EdgeChromium su Windows)
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.edgechromium",
|
||||
"--hidden-import", "clr",
|
||||
"--hidden-import", "requests",
|
||||
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
|
||||
"--collect-all", "webview",
|
||||
# Binari bundled
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
str(ROOT / "main.py"),
|
||||
]
|
||||
|
||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||
|
||||
exe_path = DIST_DIR / "MusicDownload" / "MusicDownload.exe"
|
||||
if exe_path.exists():
|
||||
log(f"Build completata: {exe_path}")
|
||||
else:
|
||||
log("ATTENZIONE: MusicDownload.exe non trovato, controlla l'output")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main
|
||||
# =========================================================================
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" MusicDownload - Build Windows")
|
||||
print("=" * 50)
|
||||
|
||||
if sys.platform != "win32":
|
||||
print("\nATTENZIONE: Questo script va eseguito su Windows!")
|
||||
print("PyInstaller non supporta cross-compilation.")
|
||||
print("Copia il progetto su una macchina Windows e rilancia.")
|
||||
sys.exit(1)
|
||||
|
||||
# Verifica pyinstaller
|
||||
try:
|
||||
import PyInstaller
|
||||
log(f"PyInstaller {PyInstaller.__version__}")
|
||||
except ImportError:
|
||||
log("Installo PyInstaller...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "pyinstaller"],
|
||||
check=True)
|
||||
|
||||
# Pulisci bundle precedente
|
||||
if BUNDLE_DIR.exists():
|
||||
shutil.rmtree(BUNDLE_DIR)
|
||||
|
||||
download_ytdlp()
|
||||
download_ffmpeg()
|
||||
run_pyinstaller()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(" DONE!")
|
||||
print("=" * 50)
|
||||
dist = DIST_DIR / "MusicDownload"
|
||||
if dist.exists():
|
||||
print(f"\n L'app e in: {dist}")
|
||||
print(f" Eseguibile: {dist / 'MusicDownload.exe'}")
|
||||
print(f"\n Per distribuirla, comprimi la cartella:")
|
||||
print(f" Comprimi {dist} -> MusicDownload_Windows.zip")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
"bitrate": "320K",
|
||||
"hq_threshold": 310,
|
||||
"cookies_path": "",
|
||||
"output_dir": "",
|
||||
"theme": "dark"
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,64 @@
|
||||
"""Gestione configurazione persistente (JSON)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.0"
|
||||
|
||||
|
||||
def _get_config_dir() -> Path:
|
||||
"""Ritorna la directory per config.json.
|
||||
|
||||
Frozen (bundle PyInstaller):
|
||||
- macOS: ~/Library/Application Support/MusicDownload/
|
||||
- Windows: %APPDATA%/MusicDownload/
|
||||
Dev (non frozen):
|
||||
- directory del progetto
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA", Path.home()))
|
||||
else:
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
config_dir = base / "MusicDownload"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
CONFIG_PATH = _get_config_dir() / "config.json"
|
||||
|
||||
# Default: i path relativi al progetto (se non frozen)
|
||||
_project_dir = Path(__file__).resolve().parent.parent
|
||||
|
||||
DEFAULTS = {
|
||||
"client_id": "",
|
||||
"client_secret": "",
|
||||
"bitrate": "320K",
|
||||
"hq_threshold": 310,
|
||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||
"output_dir": str(_project_dir / "MUSICA"),
|
||||
"theme": "dark",
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Carica la configurazione da config.json, con fallback ai default."""
|
||||
config = dict(DEFAULTS)
|
||||
if CONFIG_PATH.exists():
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
stored = json.load(f)
|
||||
config.update(stored)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return config
|
||||
|
||||
|
||||
def save_config(config: dict) -> None:
|
||||
"""Salva la configurazione su config.json."""
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Download da YouTube via yt-dlp (subprocess per usare la versione piu aggiornata)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir
|
||||
|
||||
|
||||
# Flag globale per interruzione
|
||||
_stop_event = threading.Event()
|
||||
_current_process: Optional[subprocess.Popen] = None
|
||||
_process_lock = threading.Lock()
|
||||
|
||||
|
||||
def request_stop():
|
||||
"""Richiede l'interruzione del download in corso."""
|
||||
_stop_event.set()
|
||||
with _process_lock:
|
||||
if _current_process and _current_process.poll() is None:
|
||||
_current_process.terminate()
|
||||
|
||||
|
||||
def reset_stop():
|
||||
_stop_event.clear()
|
||||
|
||||
|
||||
def is_stopped() -> bool:
|
||||
return _stop_event.is_set()
|
||||
|
||||
|
||||
def _load_done_set(done_file: Path) -> set[str]:
|
||||
"""Carica l'elenco delle tracce gia scaricate."""
|
||||
if done_file.exists():
|
||||
try:
|
||||
return set(done_file.read_text(encoding="utf-8").splitlines())
|
||||
except OSError:
|
||||
return set()
|
||||
return set()
|
||||
|
||||
|
||||
def _mark_done(done_file: Path, key: str) -> None:
|
||||
"""Aggiunge una traccia al file delle tracce scaricate."""
|
||||
try:
|
||||
with open(done_file, "a", encoding="utf-8") as f:
|
||||
f.write(key + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
_STOPWORDS = {
|
||||
"official", "video", "audio", "music", "lyric", "lyrics", "hd", "4k",
|
||||
"remix", "edit", "extended", "feat", "ft", "featuring", "with",
|
||||
"from", "the", "and", "of", "a", "an", "to", "in",
|
||||
}
|
||||
|
||||
|
||||
def _tokenize(s: str) -> set[str]:
|
||||
"""Estrae token alfanumerici significativi da una stringa."""
|
||||
s = s.lower()
|
||||
tokens = re.findall(r"[a-z0-9]+", s)
|
||||
return {t for t in tokens if len(t) >= 2 and t not in _STOPWORDS}
|
||||
|
||||
|
||||
def _scan_existing_files(directory: Path, extensions: tuple[str, ...] = (".mp3", ".m4a", ".opus", ".webm", ".wav", ".flac")) -> list[set[str]]:
|
||||
"""Ritorna lista di set di token dei filename presenti nella directory."""
|
||||
if not directory.exists():
|
||||
return []
|
||||
return [_tokenize(f.stem) for f in directory.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in extensions]
|
||||
|
||||
|
||||
def _file_exists_for_query(query: str, existing_tokens: list[set[str]]) -> bool:
|
||||
"""Verifica se esiste gia un file il cui titolo contiene tutte le parole del query.
|
||||
Match basato sui token: tutte le parole significative del query devono essere
|
||||
presenti nel filename (l'ordine non conta, eventuali parole extra nel filename ok)."""
|
||||
query_tokens = _tokenize(query)
|
||||
if not query_tokens:
|
||||
return False
|
||||
for file_tokens in existing_tokens:
|
||||
if query_tokens.issubset(file_tokens):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str, str]:
|
||||
"""Cerca un video su YouTube. Ritorna (video_url, video_title)."""
|
||||
ytdlp = find_ytdlp()
|
||||
cmd = [
|
||||
ytdlp,
|
||||
f"ytsearch1:{query}",
|
||||
"--dump-json",
|
||||
"--no-download",
|
||||
"--no-warnings",
|
||||
"--flat-playlist",
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
video_id = data.get("id", "")
|
||||
title = data.get("title", query)
|
||||
if not video_id:
|
||||
raise ValueError("Nessun risultato trovato")
|
||||
|
||||
return f"https://www.youtube.com/watch?v={video_id}", title
|
||||
|
||||
|
||||
def download_playlist(
|
||||
tracks: list[dict],
|
||||
output_dir: str,
|
||||
bitrate: str = "320K",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica tutti i brani dalla lista di tracce.
|
||||
|
||||
Args:
|
||||
tracks: lista di {"name": ..., "artist": ...}
|
||||
output_dir: directory di destinazione
|
||||
bitrate: qualita audio (es. "320K")
|
||||
cookies_path: percorso file cookies (opzionale)
|
||||
progress_callback: callback(track_index, total, track_name, status, percent)
|
||||
"""
|
||||
reset_stop()
|
||||
total = len(tracks)
|
||||
out_path = Path(output_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
done_file = out_path / ".downloaded_tracks"
|
||||
done_set = _load_done_set(done_file)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
for i, track in enumerate(tracks):
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
query = f"{track['name']} - {track['artist']}"
|
||||
track_only = track['name']
|
||||
|
||||
# Salta brani gia scaricati in precedenza (file di tracking)
|
||||
if query in done_set:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "skipped", 100)
|
||||
continue
|
||||
|
||||
# Salta se esiste gia un file corrispondente nella cartella
|
||||
if (_file_exists_for_query(query, existing_files)
|
||||
or _file_exists_for_query(track_only, existing_files)):
|
||||
_mark_done(done_file, query)
|
||||
done_set.add(query)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "skipped", 100)
|
||||
continue
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "searching", 0)
|
||||
|
||||
# Cerca il video su YouTube
|
||||
try:
|
||||
video_url, _ = _search_youtube(query, cookies_path)
|
||||
except Exception as e:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, f"error: {e}", 0)
|
||||
continue
|
||||
|
||||
if is_stopped():
|
||||
return
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "downloading", 0)
|
||||
|
||||
# Scarica con yt-dlp subprocess
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", bitrate.rstrip("Kk"),
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
||||
video_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, query, "stopped", 0)
|
||||
return
|
||||
|
||||
# Parse progress da output yt-dlp
|
||||
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, query, "downloading", pct)
|
||||
|
||||
return_code = _current_process.wait()
|
||||
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
if return_code == 0:
|
||||
_mark_done(done_file, query)
|
||||
done_set.add(query)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, "done", 100)
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, query, 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, query, f"error: {e}", 0)
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def download_direct_url(
|
||||
url: str,
|
||||
output_dir: str,
|
||||
bitrate: str = "320K",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica direttamente da un URL supportato da yt-dlp (YouTube, SoundCloud, ecc.).
|
||||
|
||||
Supporta sia singoli video che playlist.
|
||||
Callback signature: progress_callback(idx, total, track_name, status, pct)
|
||||
"""
|
||||
global _current_process
|
||||
reset_stop()
|
||||
out_path = Path(output_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
done_file = out_path / ".downloaded_tracks"
|
||||
done_set = _load_done_set(done_file)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
# 1. Ottieni info tramite yt-dlp --dump-json
|
||||
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
|
||||
|
||||
# Ogni riga stdout e un JSON (una entry per playlist, una per singolo)
|
||||
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)
|
||||
|
||||
# 2. Scarica ogni entry
|
||||
for i, entry in enumerate(entries):
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
title = entry.get("title", f"Track {i + 1}")
|
||||
entry_url = entry.get("url") or entry.get("webpage_url") or entry.get("original_url") or url
|
||||
|
||||
# Per singoli video (non flat-playlist), usa l'URL originale
|
||||
if total == 1:
|
||||
entry_url = url
|
||||
|
||||
# Salta se gia scaricato (file di tracking)
|
||||
track_key = entry.get("id") or title
|
||||
if track_key in done_set:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "skipped", 100)
|
||||
continue
|
||||
|
||||
# Salta se esiste gia un file corrispondente nella cartella
|
||||
if _file_exists_for_query(title, existing_files):
|
||||
_mark_done(done_file, track_key)
|
||||
done_set.add(track_key)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "skipped", 100)
|
||||
continue
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "downloading", 0)
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", bitrate.rstrip("Kk"),
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)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)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
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)
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
"""Risoluzione path per binari esterni (yt-dlp, ffmpeg).
|
||||
|
||||
Quando l'app gira come bundle PyInstaller, i binari sono inclusi
|
||||
nella cartella del bundle. Altrimenti cerca nel sistema.
|
||||
|
||||
Cross-platform: macOS e Windows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
|
||||
def _is_frozen() -> bool:
|
||||
"""True se stiamo girando come bundle PyInstaller."""
|
||||
return getattr(sys, "frozen", False)
|
||||
|
||||
|
||||
def _bundle_dirs() -> list[Path]:
|
||||
"""Directories dove cercare i binari bundled (PyInstaller)."""
|
||||
dirs = []
|
||||
if _is_frozen():
|
||||
# _MEIPASS: directory dove PyInstaller estrae i file
|
||||
meipass = Path(getattr(sys, "_MEIPASS", ""))
|
||||
if meipass.exists():
|
||||
dirs.append(meipass)
|
||||
# Directory dell'eseguibile
|
||||
exe_dir = Path(sys.executable).parent
|
||||
dirs.append(exe_dir)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app)
|
||||
if not _IS_WINDOWS:
|
||||
frameworks = exe_dir.parent / "Frameworks"
|
||||
if frameworks.exists():
|
||||
dirs.append(frameworks)
|
||||
return dirs
|
||||
|
||||
|
||||
def _exe(name: str) -> str:
|
||||
"""Aggiunge .exe su Windows."""
|
||||
if _IS_WINDOWS and not name.endswith(".exe"):
|
||||
return name + ".exe"
|
||||
return name
|
||||
|
||||
|
||||
def find_ytdlp() -> str:
|
||||
"""Trova il path di yt-dlp.
|
||||
|
||||
Ordine di ricerca:
|
||||
1. Bundle PyInstaller
|
||||
2. Homebrew (macOS) / PATH comune (Windows)
|
||||
3. Qualsiasi posizione nel PATH
|
||||
"""
|
||||
name = _exe("yt-dlp")
|
||||
|
||||
# 1. Bundle
|
||||
for d in _bundle_dirs():
|
||||
candidate = d / name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
# 2. Percorsi noti per OS
|
||||
if not _IS_WINDOWS:
|
||||
brew = shutil.which("yt-dlp", path="/opt/homebrew/bin:/usr/local/bin")
|
||||
if brew:
|
||||
return brew
|
||||
|
||||
# 3. PATH generico
|
||||
system = shutil.which("yt-dlp")
|
||||
if system:
|
||||
return system
|
||||
|
||||
msg = "yt-dlp non trovato. "
|
||||
if _IS_WINDOWS:
|
||||
msg += "Scaricalo da https://github.com/yt-dlp/yt-dlp/releases"
|
||||
else:
|
||||
msg += "Installalo con: brew install yt-dlp"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def find_ffmpeg_dir() -> Optional[str]:
|
||||
"""Trova la directory contenente ffmpeg e ffprobe.
|
||||
|
||||
Ordine di ricerca:
|
||||
1. Bundle PyInstaller
|
||||
2. Percorsi noti per OS
|
||||
3. Qualsiasi posizione nel PATH
|
||||
"""
|
||||
ffmpeg_name = _exe("ffmpeg")
|
||||
|
||||
# 1. Bundle
|
||||
for d in _bundle_dirs():
|
||||
if (d / ffmpeg_name).exists():
|
||||
return str(d)
|
||||
|
||||
# 2. Percorsi noti
|
||||
if _IS_WINDOWS:
|
||||
# Posizioni comuni su Windows
|
||||
for search in (
|
||||
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
|
||||
Path("C:/ffmpeg/bin"),
|
||||
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
|
||||
):
|
||||
if (search / "ffmpeg.exe").exists():
|
||||
return str(search)
|
||||
else:
|
||||
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
|
||||
if (Path(search_path) / "ffmpeg").exists():
|
||||
return search_path
|
||||
|
||||
# 3. PATH generico
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
return str(Path(ffmpeg).parent)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_ffmpeg() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffmpeg."""
|
||||
d = find_ffmpeg_dir()
|
||||
if d:
|
||||
return str(Path(d) / _exe("ffmpeg"))
|
||||
return None
|
||||
|
||||
|
||||
def find_ffprobe() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffprobe."""
|
||||
d = find_ffmpeg_dir()
|
||||
if d:
|
||||
return str(Path(d) / _exe("ffprobe"))
|
||||
return None
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Client API Spotify — autenticazione e fetch playlist/track/album."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import requests
|
||||
|
||||
|
||||
def get_access_token(client_id: str, client_secret: str) -> str:
|
||||
"""Ottiene un access token tramite Client Credentials Flow."""
|
||||
resp = requests.post(
|
||||
"https://accounts.spotify.com/api/token",
|
||||
data={"grant_type": "client_credentials"},
|
||||
auth=(client_id, client_secret),
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
token = resp.json().get("access_token")
|
||||
if not token:
|
||||
raise ValueError("Token non ricevuto da Spotify")
|
||||
return token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Riconoscimento tipo URL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_url_type(url: str) -> tuple[str, str]:
|
||||
"""Rileva il tipo di URL Spotify e ne estrae l'ID.
|
||||
|
||||
Ritorna (tipo, id) dove tipo e "track", "album" o "playlist".
|
||||
Solleva ValueError se l'URL non e riconosciuto.
|
||||
"""
|
||||
for kind in ("track", "album", "playlist"):
|
||||
match = re.search(rf"{kind}/([a-zA-Z0-9]+)", url)
|
||||
if match:
|
||||
return kind, match.group(1)
|
||||
raise ValueError(
|
||||
"URL non riconosciuto. Sono supportati: playlist, album e singoli brani."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singolo brano
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_track_info(token: str, track_id: str) -> tuple[str, list[dict]]:
|
||||
"""Ritorna (track_name, [{"name": ..., "artist": ...}]) per un singolo brano."""
|
||||
resp = requests.get(
|
||||
f"https://api.spotify.com/v1/tracks/{track_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
name = data.get("name", "Unknown")
|
||||
artists = data.get("artists", [])
|
||||
artist = artists[0].get("name", "Unknown") if artists else "Unknown"
|
||||
return name, [{"name": name, "artist": artist}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Album
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_album_info(token: str, album_id: str) -> tuple[str, list[dict]]:
|
||||
"""Ritorna (album_name, lista tracce) per un album."""
|
||||
resp = requests.get(
|
||||
f"https://api.spotify.com/v1/albums/{album_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
album_name = data.get("name", "Unknown Album")
|
||||
|
||||
tracks = []
|
||||
# Prima pagina inclusa nella risposta
|
||||
tracks_data = data.get("tracks", {})
|
||||
_collect_album_tracks(tracks_data, tracks)
|
||||
|
||||
# Paginazione se ci sono piu di 50 tracce
|
||||
next_url = tracks_data.get("next")
|
||||
while next_url:
|
||||
resp = requests.get(
|
||||
next_url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
page = resp.json()
|
||||
_collect_album_tracks(page, tracks)
|
||||
next_url = page.get("next")
|
||||
|
||||
return album_name, tracks
|
||||
|
||||
|
||||
def _collect_album_tracks(data: dict, out: list[dict]) -> None:
|
||||
for item in data.get("items", []):
|
||||
name = item.get("name", "Unknown")
|
||||
artists = item.get("artists", [])
|
||||
artist = artists[0].get("name", "Unknown") if artists else "Unknown"
|
||||
out.append({"name": name, "artist": artist})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playlist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_playlist_info(token: str, playlist_id: str) -> tuple[str, list[dict]]:
|
||||
"""Ritorna (playlist_name, lista tracce) per una playlist."""
|
||||
resp = requests.get(
|
||||
f"https://api.spotify.com/v1/playlists/{playlist_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
raise ValueError("Impossibile leggere il nome della playlist")
|
||||
|
||||
tracks = get_all_tracks(token, playlist_id)
|
||||
return name, tracks
|
||||
|
||||
|
||||
def get_all_tracks(token: str, playlist_id: str) -> list[dict]:
|
||||
"""Scarica tutte le tracce con paginazione (100 per pagina).
|
||||
|
||||
Ritorna lista di {"name": ..., "artist": ...}.
|
||||
"""
|
||||
tracks: list[dict] = []
|
||||
url: str | None = f"https://api.spotify.com/v1/playlists/{playlist_id}/tracks?limit=100&offset=0"
|
||||
|
||||
while url:
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
for item in data.get("items", []):
|
||||
track = item.get("track")
|
||||
if not track:
|
||||
continue
|
||||
name = track.get("name", "Unknown")
|
||||
artists = track.get("artists", [])
|
||||
artist = artists[0].get("name", "Unknown") if artists else "Unknown"
|
||||
tracks.append({"name": name, "artist": artist})
|
||||
|
||||
url = data.get("next") # None quando non ci sono altre pagine
|
||||
|
||||
return tracks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Funzione unificata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]:
|
||||
"""Risolve qualsiasi URL Spotify supportato.
|
||||
|
||||
Ritorna (label, nome, lista_tracce) dove label e "Brano"/"Album"/"Playlist".
|
||||
"""
|
||||
kind, item_id = detect_url_type(url)
|
||||
|
||||
if kind == "track":
|
||||
name, tracks = get_track_info(token, item_id)
|
||||
return "Brano", name, tracks
|
||||
elif kind == "album":
|
||||
name, tracks = get_album_info(token, item_id)
|
||||
return "Album", name, tracks
|
||||
else:
|
||||
name, tracks = get_playlist_info(token, item_id)
|
||||
return "Playlist", name, tracks
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Logica upgrade qualita audio — riscrittura di upgrade_quality.sh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
||||
|
||||
# Flag globale per interruzione
|
||||
_stop_event = threading.Event()
|
||||
_current_process: Optional[subprocess.Popen] = None
|
||||
_process_lock = threading.Lock()
|
||||
|
||||
|
||||
def request_stop():
|
||||
_stop_event.set()
|
||||
with _process_lock:
|
||||
if _current_process and _current_process.poll() is None:
|
||||
_current_process.terminate()
|
||||
|
||||
|
||||
def reset_stop():
|
||||
_stop_event.clear()
|
||||
|
||||
|
||||
def is_stopped() -> bool:
|
||||
return _stop_event.is_set()
|
||||
|
||||
|
||||
def scan_audio_files(directory: str, recursive: bool = False) -> list[Path]:
|
||||
"""Trova file audio nella directory."""
|
||||
base = Path(directory)
|
||||
files = []
|
||||
if recursive:
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
files.extend(base.rglob(f"*{ext}"))
|
||||
else:
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
files.extend(base.glob(f"*{ext}"))
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def get_bitrate(filepath: str | Path) -> int:
|
||||
"""Ritorna il bitrate in kbps tramite ffprobe. Ritorna 0 in caso di errore."""
|
||||
try:
|
||||
ffprobe_bin = find_ffprobe() or "ffprobe"
|
||||
result = subprocess.run(
|
||||
[
|
||||
ffprobe_bin, "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
str(filepath),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
bit_rate = int(data.get("format", {}).get("bit_rate", 0))
|
||||
return bit_rate // 1000
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _load_done_set(done_file: Path) -> set[str]:
|
||||
if done_file.exists():
|
||||
return set(done_file.read_text(encoding="utf-8").splitlines())
|
||||
return set()
|
||||
|
||||
|
||||
def _mark_done(done_file: Path, filename: str) -> None:
|
||||
with open(done_file, "a", encoding="utf-8") as f:
|
||||
f.write(filename + "\n")
|
||||
|
||||
|
||||
def _search_youtube(query: str, cookies_path: Optional[str] = None) -> str:
|
||||
"""Cerca un video su YouTube e ritorna l'URL."""
|
||||
ytdlp = find_ytdlp()
|
||||
cmd = [
|
||||
ytdlp,
|
||||
f"ytsearch1:{query}",
|
||||
"--dump-json",
|
||||
"--no-download",
|
||||
"--no-warnings",
|
||||
"--flat-playlist",
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise ValueError("Ricerca fallita")
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
video_id = data.get("id", "")
|
||||
if not video_id:
|
||||
raise ValueError("Nessun risultato")
|
||||
return f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
|
||||
def update_cover_only(
|
||||
filepath: Path,
|
||||
video_url: str,
|
||||
temp_dir: Path,
|
||||
cookies_path: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Aggiorna solo la copertina di un file audio. Ritorna True se riuscito."""
|
||||
ytdlp = find_ytdlp()
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--skip-download",
|
||||
"--write-thumbnail",
|
||||
"--convert-thumbnails", "jpg",
|
||||
"--no-warnings",
|
||||
"--output", str(temp_dir / "cover"),
|
||||
video_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])
|
||||
|
||||
subprocess.run(cmd, capture_output=True, timeout=60)
|
||||
|
||||
cover_files = list(temp_dir.glob("cover*.jpg"))
|
||||
if not cover_files:
|
||||
return False
|
||||
|
||||
cover_file = cover_files[0]
|
||||
temp_output = temp_dir / "temp_output.mp3"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(filepath),
|
||||
"-i", str(cover_file),
|
||||
"-map", "0:a", "-map", "1:0",
|
||||
"-c:a", "copy",
|
||||
"-id3v2_version", "3",
|
||||
"-metadata:s:v", "title=Album cover",
|
||||
"-metadata:s:v", "comment=Cover (front)",
|
||||
str(temp_output),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
except Exception:
|
||||
_cleanup_temp(temp_dir)
|
||||
return False
|
||||
|
||||
if temp_output.exists():
|
||||
temp_output.replace(filepath)
|
||||
_cleanup_temp(temp_dir)
|
||||
return True
|
||||
|
||||
_cleanup_temp(temp_dir)
|
||||
return False
|
||||
|
||||
|
||||
def _cleanup_temp(temp_dir: Path) -> None:
|
||||
for pattern in ("*.jpg", "*.webp", "*.mp3", "*.webm", "*.m4a"):
|
||||
for f in temp_dir.glob(pattern):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def upgrade_folder(
|
||||
directory: str,
|
||||
threshold: int = 310,
|
||||
cookies_path: Optional[str] = None,
|
||||
recursive: bool = False,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Logica principale di upgrade qualita."""
|
||||
reset_stop()
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
if recursive:
|
||||
base = Path(directory)
|
||||
folders = set()
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
for f in base.rglob(f"*{ext}"):
|
||||
folders.add(f.parent)
|
||||
folders = sorted(folders)
|
||||
else:
|
||||
folders = [Path(directory)]
|
||||
|
||||
all_items: list[tuple[Path, Path]] = []
|
||||
for folder in folders:
|
||||
for ext in AUDIO_EXTENSIONS:
|
||||
for f in sorted(folder.glob(f"*{ext}")):
|
||||
all_items.append((f, folder))
|
||||
|
||||
total = len(all_items)
|
||||
if total == 0:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, "", "no_files", 0, 0)
|
||||
return
|
||||
|
||||
processed = 0
|
||||
|
||||
for filepath, folder in all_items:
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, "", "stopped", 0, 0)
|
||||
return
|
||||
|
||||
done_file = folder / ".upgraded_tracks"
|
||||
temp_dir = folder / ".temp_download"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
done_set = _load_done_set(done_file)
|
||||
filename = filepath.stem
|
||||
|
||||
if filename in done_set:
|
||||
processed += 1
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "skipped", 0, 0)
|
||||
continue
|
||||
|
||||
current_kbps = get_bitrate(filepath)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "searching", current_kbps, 0)
|
||||
|
||||
query = filename
|
||||
try:
|
||||
video_url = _search_youtube(query, cookies_path)
|
||||
except Exception:
|
||||
_mark_done(done_file, filename)
|
||||
processed += 1
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "not_found", current_kbps, 0)
|
||||
continue
|
||||
|
||||
if is_stopped():
|
||||
return
|
||||
|
||||
# Se qualita gia alta, aggiorna solo copertina
|
||||
if current_kbps >= threshold:
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "cover_only", current_kbps, 0)
|
||||
|
||||
success = update_cover_only(filepath, video_url, temp_dir, cookies_path)
|
||||
_mark_done(done_file, filename)
|
||||
processed += 1
|
||||
|
||||
status = "cover_done" if success else "cover_failed"
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, status, current_kbps, current_kbps)
|
||||
continue
|
||||
|
||||
# Scarica versione HQ
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "downloading", current_kbps, 0)
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", "0",
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"--no-warnings",
|
||||
"--newline",
|
||||
"--output", str(temp_dir / "%(title)s.%(ext)s"),
|
||||
video_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()
|
||||
return
|
||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||
if pct_match and progress_callback:
|
||||
pct = int(float(pct_match.group(1)))
|
||||
progress_callback(processed, total, filepath.name, "downloading", current_kbps, pct)
|
||||
|
||||
_current_process.wait()
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
except Exception:
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
_mark_done(done_file, filename)
|
||||
processed += 1
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "download_error", current_kbps, 0)
|
||||
_cleanup_temp(temp_dir)
|
||||
continue
|
||||
|
||||
# Trova il file scaricato
|
||||
new_files = list(temp_dir.glob("*.mp3"))
|
||||
if not new_files:
|
||||
_mark_done(done_file, filename)
|
||||
processed += 1
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "download_error", current_kbps, 0)
|
||||
_cleanup_temp(temp_dir)
|
||||
continue
|
||||
|
||||
newest = max(new_files, key=lambda p: p.stat().st_mtime)
|
||||
new_kbps = get_bitrate(newest)
|
||||
|
||||
filepath.unlink()
|
||||
newest.replace(filepath.parent / f"{filename}.mp3")
|
||||
|
||||
_mark_done(done_file, filename)
|
||||
_cleanup_temp(temp_dir)
|
||||
processed += 1
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(processed, total, filepath.name, "upgraded", current_kbps, new_kbps)
|
||||
|
||||
for folder in folders:
|
||||
td = folder / ".temp_download"
|
||||
if td.exists():
|
||||
try:
|
||||
td.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 0, 0)
|
||||
|
||||
|
||||
def count_files_info(directory: str, recursive: bool = False) -> tuple[int, int]:
|
||||
"""Ritorna (total_audio_files, already_upgraded)."""
|
||||
files = scan_audio_files(directory, recursive)
|
||||
total = len(files)
|
||||
|
||||
already_done = 0
|
||||
for f in files:
|
||||
done_file = f.parent / ".upgraded_tracks"
|
||||
done_set = _load_done_set(done_file)
|
||||
if f.stem in done_set:
|
||||
already_done += 1
|
||||
|
||||
return total, already_done
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
@@ -0,0 +1,42 @@
|
||||
"""MusicDownload — Entry point (pywebview + WebKit/EdgeChromium)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import webview
|
||||
|
||||
from api.bridge import Api
|
||||
|
||||
|
||||
def _resource_path(*parts) -> str:
|
||||
"""Trova il path di una risorsa sia in dev che bundled (PyInstaller)."""
|
||||
if getattr(sys, "frozen", False):
|
||||
base = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
else:
|
||||
base = Path(__file__).resolve().parent
|
||||
return str(base.joinpath(*parts))
|
||||
|
||||
|
||||
def main():
|
||||
api = Api()
|
||||
index_path = _resource_path("webui", "index.html")
|
||||
|
||||
window = webview.create_window(
|
||||
title="MusicDownload",
|
||||
url=index_path,
|
||||
js_api=api,
|
||||
width=1080,
|
||||
height=720,
|
||||
min_size=(900, 600),
|
||||
background_color="#0A0A0A",
|
||||
resizable=True,
|
||||
)
|
||||
api.window = window
|
||||
|
||||
# Disable native menu / set dark on macOS
|
||||
webview.start(debug=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
pywebview>=5.0
|
||||
requests>=2.31.0
|
||||
yt-dlp>=2024.0.0
|
||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet>=3.0 ; sys_platform == "win32"
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# 🎵 Spotify → YouTube Downloader (API edition)
|
||||
# by LuZa / GPT-5
|
||||
# ============================================
|
||||
|
||||
# Requisiti: yt-dlp, ffmpeg, jq, curl
|
||||
# Installazione rapida (macOS):
|
||||
# brew install yt-dlp ffmpeg jq curl
|
||||
|
||||
# ====== CONFIGURAZIONE ======
|
||||
CLIENT_ID="379c38c32a5b43259cf55125e6efd472"
|
||||
CLIENT_SECRET="e58c9a468b624eb3bdd27f701fef3aa2"
|
||||
BITRATE="320K"
|
||||
COOKIE_FILE="cookies.txt"
|
||||
LOGFILE="download.log"
|
||||
|
||||
# ====== INPUT ======
|
||||
if [ -z "$1" ]; then
|
||||
echo "Uso: $0 <link_playlist_spotify> [directory_output]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SPOTIFY_URL="$1"
|
||||
CUSTOM_DIR="$2"
|
||||
|
||||
# ====== SCELTA DIRECTORY ======
|
||||
if [ -z "$CUSTOM_DIR" ]; then
|
||||
echo "📁 Inserisci il percorso della cartella di destinazione:"
|
||||
read -r CUSTOM_DIR
|
||||
fi
|
||||
|
||||
mkdir -p "$CUSTOM_DIR" || { echo "❌ Impossibile creare la directory $CUSTOM_DIR"; exit 1; }
|
||||
|
||||
# ====== OTTIENI TOKEN ACCESSO ======
|
||||
echo "🔑 Recupero token di accesso Spotify..."
|
||||
ACCESS_TOKEN=$(curl -s -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \
|
||||
-d grant_type=client_credentials https://accounts.spotify.com/api/token \
|
||||
| jq -r '.access_token')
|
||||
|
||||
if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
|
||||
echo "❌ Errore durante il recupero del token Spotify."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ====== ESTRAI ID PLAYLIST ======
|
||||
PLAYLIST_ID=$(echo "$SPOTIFY_URL" | awk -F'playlist/' '{print $2}' | cut -d'?' -f1)
|
||||
PLAYLIST_API="https://api.spotify.com/v1/playlists/$PLAYLIST_ID"
|
||||
|
||||
# ====== OTTIENI INFO PLAYLIST ======
|
||||
PLAYLIST_NAME=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$PLAYLIST_API" | jq -r '.name')
|
||||
if [ "$PLAYLIST_NAME" = "null" ] || [ -z "$PLAYLIST_NAME" ]; then
|
||||
echo "❌ Impossibile leggere la playlist. Controlla il link o le credenziali."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEST_DIR="$CUSTOM_DIR/$PLAYLIST_NAME"
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
echo "🎧 Playlist: $PLAYLIST_NAME"
|
||||
echo "📂 Download in: $DEST_DIR"
|
||||
echo "------------------------------------------"
|
||||
echo "📝 Log: $DEST_DIR/$LOGFILE"
|
||||
|
||||
# ====== OTTIENI TUTTI I BRANI (paginazione API) ======
|
||||
OFFSET=0
|
||||
LIMIT=100
|
||||
|
||||
while :; do
|
||||
TRACKS_JSON=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
"$PLAYLIST_API/tracks?limit=$LIMIT&offset=$OFFSET")
|
||||
|
||||
ITEMS=$(echo "$TRACKS_JSON" | jq -r '.items[]?.track.name + " - " + (.items[]?.track.artists[0].name // "Unknown")')
|
||||
if [ -z "$ITEMS" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
echo "$TRACKS_JSON" | jq -r '.items[] | "\(.track.name) - \(.track.artists[0].name)"' >> "$DEST_DIR/tracks.txt"
|
||||
|
||||
NEXT=$(echo "$TRACKS_JSON" | jq -r '.next')
|
||||
[ "$NEXT" = "null" ] && break
|
||||
OFFSET=$((OFFSET + LIMIT))
|
||||
done
|
||||
|
||||
# ====== SCARICA I BRANI ======
|
||||
INDEX=0
|
||||
while IFS= read -r QUERY; do
|
||||
((INDEX++))
|
||||
echo "🔍 [$INDEX] Cerco su YouTube: $QUERY"
|
||||
VIDEO_ID=$(yt-dlp "ytsearch1:$QUERY" --get-id 2>/dev/null | head -n 1)
|
||||
if [ -z "$VIDEO_ID" ]; then
|
||||
echo "❌ Non trovato: $QUERY" | tee -a "$DEST_DIR/$LOGFILE"
|
||||
continue
|
||||
fi
|
||||
VIDEO_URL="https://www.youtube.com/watch?v=$VIDEO_ID"
|
||||
echo "🎬 Trovato: $VIDEO_URL"
|
||||
|
||||
yt-dlp \
|
||||
--extract-audio \
|
||||
--audio-format mp3 \
|
||||
--audio-quality "$BITRATE" \
|
||||
--embed-thumbnail \
|
||||
--add-metadata \
|
||||
--no-check-certificates \
|
||||
--cookies "$COOKIE_FILE" \
|
||||
--output "$DEST_DIR/%(title)s.%(ext)s" \
|
||||
"$VIDEO_URL" >>"$DEST_DIR/$LOGFILE" 2>&1
|
||||
|
||||
echo "✅ Scaricato: $QUERY"
|
||||
echo "------------------------------------------"
|
||||
done < "$DEST_DIR/tracks.txt"
|
||||
|
||||
echo "🏁 Download completato!"
|
||||
echo "📂 File salvati in: $DEST_DIR"
|
||||
Executable
+428
@@ -0,0 +1,428 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Upgrade Quality - Cerca versioni HQ dei brani
|
||||
# Sovrascrive i file originali
|
||||
# Con progress bar e ripresa automatica
|
||||
# ============================================
|
||||
|
||||
# Requisiti: yt-dlp, ffmpeg, ffprobe
|
||||
# Installazione: brew install yt-dlp ffmpeg
|
||||
|
||||
# Percorso script (per trovare cookies.txt nella stessa cartella)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COOKIE_FILE="$SCRIPT_DIR/cookies.txt"
|
||||
|
||||
# Colori
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
GRAY='\033[0;90m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
BOLD='\033[1m'
|
||||
|
||||
# Soglia qualita (kbps) - sopra questa aggiorna solo copertina
|
||||
HQ_THRESHOLD=310
|
||||
|
||||
# ============================================
|
||||
# Funzione progress bar
|
||||
# ============================================
|
||||
progress_bar() {
|
||||
local current=$1
|
||||
local total=$2
|
||||
local width=30
|
||||
local percent=$((current * 100 / total))
|
||||
local filled=$((current * width / total))
|
||||
local empty=$((width - filled))
|
||||
|
||||
printf "\r ["
|
||||
printf "%${filled}s" | tr ' ' '#'
|
||||
printf "%${empty}s" | tr ' ' '-'
|
||||
printf "] %3d%% (%d/%d)" "$percent" "$current" "$total"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Funzione per aggiornare solo la copertina
|
||||
# ============================================
|
||||
update_cover_only() {
|
||||
local FILE="$1"
|
||||
local VIDEO_URL="$2"
|
||||
local TEMP_DIR="$3"
|
||||
local FILENAME="$4"
|
||||
|
||||
# Scarica thumbnail
|
||||
yt-dlp \
|
||||
--skip-download \
|
||||
--write-thumbnail \
|
||||
--convert-thumbnails jpg \
|
||||
--no-warnings \
|
||||
$COOKIE_OPTS \
|
||||
--output "$TEMP_DIR/cover" \
|
||||
"$VIDEO_URL" 2>/dev/null
|
||||
|
||||
local COVER_FILE=$(ls "$TEMP_DIR"/cover*.jpg 2>/dev/null | head -n 1)
|
||||
|
||||
if [ -n "$COVER_FILE" ] && [ -f "$COVER_FILE" ]; then
|
||||
# Applica copertina con ffmpeg
|
||||
local TEMP_OUTPUT="$TEMP_DIR/temp_output.mp3"
|
||||
|
||||
ffmpeg -y -i "$FILE" -i "$COVER_FILE" \
|
||||
-map 0:a -map 1:0 \
|
||||
-c:a copy \
|
||||
-id3v2_version 3 \
|
||||
-metadata:s:v title="Album cover" \
|
||||
-metadata:s:v comment="Cover (front)" \
|
||||
"$TEMP_OUTPUT" 2>/dev/null
|
||||
|
||||
if [ -f "$TEMP_OUTPUT" ]; then
|
||||
mv "$TEMP_OUTPUT" "$FILE"
|
||||
rm -f "$COVER_FILE"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$TEMP_DIR"/cover*.jpg 2>/dev/null
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Funzione per processare una singola cartella
|
||||
# ============================================
|
||||
process_folder() {
|
||||
local SOURCE_DIR="$1"
|
||||
local LOGFILE="$SOURCE_DIR/upgrade.log"
|
||||
local DONE_FILE="$SOURCE_DIR/.upgraded_tracks"
|
||||
local TEMP_DIR="$SOURCE_DIR/.temp_download"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo -e "${CYAN} Cartella: ${YELLOW}$(basename "$SOURCE_DIR")${NC}"
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
|
||||
# Crea file done se non esiste
|
||||
touch "$DONE_FILE"
|
||||
|
||||
# Conta file totali e da processare
|
||||
local ALL_FILES=$(find "$SOURCE_DIR" -maxdepth 1 -type f \( -iname "*.mp3" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.flac" \) | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$ALL_FILES" -eq 0 ]; then
|
||||
echo -e " ${YELLOW}Nessun file audio, salto${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Conta quanti sono gia stati convertiti
|
||||
local ALREADY_DONE=0
|
||||
while IFS= read -r FILE; do
|
||||
local BASENAME=$(basename "$FILE")
|
||||
local FILENAME="${BASENAME%.*}"
|
||||
if grep -qxF "$FILENAME" "$DONE_FILE" 2>/dev/null; then
|
||||
((ALREADY_DONE++))
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -maxdepth 1 -type f \( -iname "*.mp3" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.flac" \))
|
||||
|
||||
local TO_PROCESS=$((ALL_FILES - ALREADY_DONE))
|
||||
|
||||
if [ "$TO_PROCESS" -eq 0 ]; then
|
||||
echo -e " ${GREEN}Tutti i $ALL_FILES brani sono gia stati convertiti${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$ALREADY_DONE" -gt 0 ]; then
|
||||
echo -e " Totale: $ALL_FILES file | ${GREEN}Gia convertiti: $ALREADY_DONE${NC} | Da fare: $TO_PROCESS"
|
||||
else
|
||||
echo " Trovati $ALL_FILES file audio da processare"
|
||||
fi
|
||||
|
||||
# Crea directory temporanea
|
||||
mkdir -p "$TEMP_DIR"
|
||||
|
||||
local INDEX=0
|
||||
local SUCCESS=0
|
||||
local FAILED=0
|
||||
local SKIPPED=0
|
||||
|
||||
# Processa ogni file audio
|
||||
while IFS= read -r FILE; do
|
||||
((INDEX++))
|
||||
|
||||
# Estrai nome file senza estensione
|
||||
local BASENAME=$(basename "$FILE")
|
||||
local FILENAME="${BASENAME%.*}"
|
||||
|
||||
# Controlla se gia convertito
|
||||
if grep -qxF "$FILENAME" "$DONE_FILE" 2>/dev/null; then
|
||||
((SKIPPED++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Analizza qualita attuale
|
||||
local CURRENT_BITRATE=$(ffprobe -v quiet -print_format json -show_format "$FILE" 2>/dev/null | grep -o '"bit_rate": "[^"]*"' | grep -o '[0-9]*')
|
||||
local CURRENT_KBPS=$((CURRENT_BITRATE / 1000))
|
||||
|
||||
# Mostra stato
|
||||
echo ""
|
||||
local DONE_COUNT=$((SKIPPED + SUCCESS + FAILED))
|
||||
echo -e " ${YELLOW}[$((DONE_COUNT + 1))/$ALL_FILES]${NC} $FILENAME"
|
||||
echo -e " ${GRAY}Qualita attuale: ${CURRENT_KBPS}kbps${NC}"
|
||||
|
||||
# Cerca su YouTube
|
||||
echo -ne " ${GRAY}Cercando su YouTube...${NC}"
|
||||
local VIDEO_INFO=$(yt-dlp "ytsearch1:$FILENAME" \
|
||||
--get-id --get-title \
|
||||
--no-warnings 2>/dev/null)
|
||||
|
||||
if [ -z "$VIDEO_INFO" ]; then
|
||||
echo -e "\r ${RED}Non trovato su YouTube ${NC}"
|
||||
echo "FALLITO: $FILENAME - Non trovato" >> "$LOGFILE"
|
||||
# Segna come fatto comunque per non riprovare
|
||||
echo "$FILENAME" >> "$DONE_FILE"
|
||||
((FAILED++))
|
||||
continue
|
||||
fi
|
||||
|
||||
local VIDEO_TITLE=$(echo "$VIDEO_INFO" | head -n 1)
|
||||
local VIDEO_ID=$(echo "$VIDEO_INFO" | tail -n 1)
|
||||
local VIDEO_URL="https://www.youtube.com/watch?v=$VIDEO_ID"
|
||||
|
||||
echo -e "\r ${GRAY}Trovato: ${VIDEO_TITLE:0:50}...${NC} "
|
||||
|
||||
# Se qualita gia alta, aggiorna solo copertina
|
||||
if [ "$CURRENT_KBPS" -ge "$HQ_THRESHOLD" ]; then
|
||||
echo -ne " ${BLUE}Qualita gia alta, aggiorno solo copertina...${NC}"
|
||||
|
||||
if update_cover_only "$FILE" "$VIDEO_URL" "$TEMP_DIR" "$FILENAME"; then
|
||||
echo -e "\r ${GREEN}Copertina aggiornata (audio ${CURRENT_KBPS}kbps mantenuto) ${NC}"
|
||||
echo "COVER: $FILENAME - Copertina aggiornata (${CURRENT_KBPS}kbps)" >> "$LOGFILE"
|
||||
else
|
||||
echo -e "\r ${YELLOW}Copertina non disponibile (audio ${CURRENT_KBPS}kbps ok) ${NC}"
|
||||
echo "SKIP: $FILENAME - Gia HQ ${CURRENT_KBPS}kbps" >> "$LOGFILE"
|
||||
fi
|
||||
|
||||
echo "$FILENAME" >> "$DONE_FILE"
|
||||
((SUCCESS++))
|
||||
|
||||
# Pulisci temp
|
||||
rm -f "$TEMP_DIR"/*.jpg 2>/dev/null
|
||||
rm -f "$TEMP_DIR"/*.webp 2>/dev/null
|
||||
continue
|
||||
fi
|
||||
|
||||
# Scarica con progress
|
||||
echo -ne " ${CYAN}Scaricando...${NC} "
|
||||
|
||||
yt-dlp \
|
||||
--extract-audio \
|
||||
--audio-format mp3 \
|
||||
--audio-quality 0 \
|
||||
--embed-thumbnail \
|
||||
--add-metadata \
|
||||
--no-warnings \
|
||||
--progress \
|
||||
--newline \
|
||||
$COOKIE_OPTS \
|
||||
--output "$TEMP_DIR/%(title)s.%(ext)s" \
|
||||
"$VIDEO_URL" 2>&1 | while IFS= read -r line; do
|
||||
# Estrai percentuale dal output di yt-dlp
|
||||
if [[ "$line" =~ ([0-9]+\.[0-9]+)% ]]; then
|
||||
pct="${BASH_REMATCH[1]}"
|
||||
pct_int=${pct%.*}
|
||||
bar_width=20
|
||||
filled=$((pct_int * bar_width / 100))
|
||||
empty=$((bar_width - filled))
|
||||
printf "\r ${CYAN}Scaricando${NC} ["
|
||||
printf "%${filled}s" | tr ' ' '#'
|
||||
printf "%${empty}s" | tr ' ' '-'
|
||||
printf "] %3d%%" "$pct_int"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $? -eq 0 ] || [ -n "$(ls -A "$TEMP_DIR" 2>/dev/null)" ]; then
|
||||
# Trova il file appena scaricato
|
||||
local NEWEST_FILE=$(ls -t "$TEMP_DIR"/*.mp3 2>/dev/null | head -n 1)
|
||||
|
||||
if [ -n "$NEWEST_FILE" ] && [ -f "$NEWEST_FILE" ]; then
|
||||
local NEW_BITRATE=$(ffprobe -v quiet -print_format json -show_format "$NEWEST_FILE" 2>/dev/null | grep -o '"bit_rate": "[^"]*"' | grep -o '[0-9]*')
|
||||
local NEW_KBPS=$((NEW_BITRATE / 1000))
|
||||
|
||||
# Elimina file originale e sposta quello nuovo
|
||||
rm -f "$FILE"
|
||||
mv "$NEWEST_FILE" "$SOURCE_DIR/$FILENAME.mp3"
|
||||
|
||||
# Segna come completato
|
||||
echo "$FILENAME" >> "$DONE_FILE"
|
||||
|
||||
local DIFF=$((NEW_KBPS - CURRENT_KBPS))
|
||||
if [ "$DIFF" -gt 0 ]; then
|
||||
echo -e "\r ${GREEN}Completato: ${NEW_KBPS}kbps (+${DIFF}kbps) ${NC}"
|
||||
else
|
||||
echo -e "\r ${GREEN}Completato: ${NEW_KBPS}kbps ${NC}"
|
||||
fi
|
||||
|
||||
echo "OK: $FILENAME - ${CURRENT_KBPS}kbps -> ${NEW_KBPS}kbps" >> "$LOGFILE"
|
||||
((SUCCESS++))
|
||||
else
|
||||
echo -e "\r ${RED}Errore: file non trovato dopo download ${NC}"
|
||||
echo "$FILENAME" >> "$DONE_FILE"
|
||||
((FAILED++))
|
||||
fi
|
||||
else
|
||||
echo -e "\r ${RED}Errore durante il download ${NC}"
|
||||
echo "ERRORE: $FILENAME - Download fallito" >> "$LOGFILE"
|
||||
echo "$FILENAME" >> "$DONE_FILE"
|
||||
((FAILED++))
|
||||
fi
|
||||
|
||||
# Pulisci temp
|
||||
rm -f "$TEMP_DIR"/*.mp3 2>/dev/null
|
||||
rm -f "$TEMP_DIR"/*.webm 2>/dev/null
|
||||
rm -f "$TEMP_DIR"/*.m4a 2>/dev/null
|
||||
|
||||
done < <(find "$SOURCE_DIR" -maxdepth 1 -type f \( -iname "*.mp3" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.flac" \))
|
||||
|
||||
# Rimuovi directory temporanea
|
||||
rmdir "$TEMP_DIR" 2>/dev/null
|
||||
|
||||
# Sommario cartella
|
||||
echo ""
|
||||
echo -e " ${GRAY}---${NC}"
|
||||
echo -e " Convertiti: ${GREEN}$SUCCESS${NC} | Falliti: ${RED}$FAILED${NC} | Saltati: ${GRAY}$SKIPPED${NC}"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# MAIN
|
||||
# ============================================
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " UPGRADE QUALITY AUDIO"
|
||||
echo "========================================"
|
||||
|
||||
# Verifica cookies YouTube Premium
|
||||
if [ -f "$COOKIE_FILE" ]; then
|
||||
echo -e "Cookies: ${GREEN}YouTube Premium attivo${NC}"
|
||||
COOKIE_OPTS="--cookies $COOKIE_FILE"
|
||||
else
|
||||
echo -e "Cookies: ${YELLOW}Non trovati (qualita standard)${NC}"
|
||||
COOKIE_OPTS=""
|
||||
fi
|
||||
|
||||
# Gestione parametri
|
||||
if [ "$1" = "-r" ] || [ "$1" = "--recursive" ]; then
|
||||
# Modalita ricorsiva: processa tutte le sottocartelle
|
||||
if [ -z "$2" ]; then
|
||||
echo -e "${CYAN}Inserisci la cartella principale:${NC}"
|
||||
read -r BASE_DIR
|
||||
else
|
||||
BASE_DIR="$2"
|
||||
fi
|
||||
|
||||
if [ ! -d "$BASE_DIR" ]; then
|
||||
echo -e "${RED}Errore: Directory '$BASE_DIR' non trovata${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Trova tutte le sottocartelle con file audio
|
||||
echo ""
|
||||
echo "Scansione cartelle in: $BASE_DIR"
|
||||
|
||||
FOLDERS=$(find "$BASE_DIR" -type d | while read -r dir; do
|
||||
count=$(find "$dir" -maxdepth 1 -type f \( -iname "*.mp3" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.flac" \) 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$count" -gt 0 ]; then
|
||||
echo "$dir"
|
||||
fi
|
||||
done)
|
||||
|
||||
if [ -z "$FOLDERS" ]; then
|
||||
echo -e "${RED}Nessuna cartella con file audio trovata${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FOLDER_COUNT=$(echo "$FOLDERS" | wc -l | tr -d ' ')
|
||||
echo ""
|
||||
echo "Trovate $FOLDER_COUNT cartelle con file audio:"
|
||||
echo "$FOLDERS" | while read -r f; do
|
||||
count=$(find "$f" -maxdepth 1 -type f \( -iname "*.mp3" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.flac" \) | wc -l | tr -d ' ')
|
||||
# Conta gia convertiti
|
||||
done_file="$f/.upgraded_tracks"
|
||||
if [ -f "$done_file" ]; then
|
||||
done_count=$(wc -l < "$done_file" | tr -d ' ')
|
||||
remaining=$((count - done_count))
|
||||
if [ "$remaining" -le 0 ]; then
|
||||
echo -e " ${GREEN}$(basename "$f")${NC} ($count file - tutti convertiti)"
|
||||
else
|
||||
echo -e " ${YELLOW}$(basename "$f")${NC} ($count file - $remaining da convertire)"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}$(basename "$f")${NC} ($count file)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}I file originali verranno sovrascritti!${NC}"
|
||||
echo -e "${GRAY}I brani gia convertiti verranno saltati automaticamente.${NC}"
|
||||
read -p "Vuoi continuare? (s/n): " CONFIRM
|
||||
if [[ ! "$CONFIRM" =~ ^[sS]$ ]]; then
|
||||
echo "Operazione annullata."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Processa ogni cartella
|
||||
FOLDER_INDEX=0
|
||||
echo "$FOLDERS" | while read -r folder; do
|
||||
((FOLDER_INDEX++))
|
||||
echo ""
|
||||
echo -e "${BOLD}>>> Cartella $FOLDER_INDEX/$FOLDER_COUNT <<<${NC}"
|
||||
process_folder "$folder"
|
||||
done
|
||||
|
||||
else
|
||||
# Modalita singola cartella
|
||||
if [ -z "$1" ]; then
|
||||
echo -e "${CYAN}Inserisci il percorso della cartella da convertire:${NC}"
|
||||
read -r SOURCE_DIR
|
||||
else
|
||||
SOURCE_DIR="$1"
|
||||
fi
|
||||
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo -e "${RED}Errore: Directory '$SOURCE_DIR' non trovata${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Conta file
|
||||
TOTAL=$(find "$SOURCE_DIR" -maxdepth 1 -type f \( -iname "*.mp3" -o -iname "*.m4a" -o -iname "*.wav" -o -iname "*.flac" \) | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$TOTAL" -eq 0 ]; then
|
||||
echo -e "${RED}Nessun file audio trovato nella cartella${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Conta gia convertiti
|
||||
DONE_FILE="$SOURCE_DIR/.upgraded_tracks"
|
||||
if [ -f "$DONE_FILE" ]; then
|
||||
DONE_COUNT=$(wc -l < "$DONE_FILE" | tr -d ' ')
|
||||
REMAINING=$((TOTAL - DONE_COUNT))
|
||||
echo -e "Cartella: ${YELLOW}$SOURCE_DIR${NC}"
|
||||
echo -e "Totale: $TOTAL file | ${GREEN}Gia convertiti: $DONE_COUNT${NC} | Da fare: $REMAINING"
|
||||
else
|
||||
echo -e "Cartella: ${YELLOW}$SOURCE_DIR${NC}"
|
||||
echo "Trovati $TOTAL file audio"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}I file originali verranno sovrascritti!${NC}"
|
||||
echo -e "${GRAY}I brani gia convertiti verranno saltati automaticamente.${NC}"
|
||||
read -p "Vuoi continuare? (s/n): " CONFIRM
|
||||
if [[ ! "$CONFIRM" =~ ^[sS]$ ]]; then
|
||||
echo "Operazione annullata."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
process_folder "$SOURCE_DIR"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo -e " ${GREEN}COMPLETATO${NC}"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,778 @@
|
||||
/* ===============================
|
||||
MusicDownload — Spotify-like UI
|
||||
=============================== */
|
||||
|
||||
:root {
|
||||
--bg: #0A0A0A;
|
||||
--bg-elev: #121212;
|
||||
--bg-card: #181818;
|
||||
--bg-card-2: #1F1F1F;
|
||||
--bg-input: #2A2A2A;
|
||||
--bg-input-hover: #3A3A3A;
|
||||
|
||||
--border: #232323;
|
||||
--divider: #1F1F1F;
|
||||
|
||||
--green: #1DB954;
|
||||
--green-hover: #1ED760;
|
||||
--green-press: #169B46;
|
||||
--green-dim: #0F3B22;
|
||||
|
||||
--blue: #509BF5;
|
||||
--blue-hover: #6FB1F7;
|
||||
--blue-dim: #1A2F4A;
|
||||
|
||||
--purple: #A855F7;
|
||||
--purple-hover: #C084FC;
|
||||
--purple-dim: #2D1B4D;
|
||||
|
||||
--red: #E22134;
|
||||
--red-hover: #FF4055;
|
||||
|
||||
--text: #FFFFFF;
|
||||
--text-2: #B3B3B3;
|
||||
--text-3: #727272;
|
||||
|
||||
--r-sm: 8px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 14px;
|
||||
--r-pill: 999px;
|
||||
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
|
||||
/* ===============================
|
||||
LAYOUT
|
||||
=============================== */
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
SIDEBAR
|
||||
=============================== */
|
||||
.sidebar {
|
||||
background: var(--bg-elev);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--green), var(--green-press));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
color: #000;
|
||||
box-shadow: 0 4px 12px rgba(29, 185, 84, 0.35);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
margin: 0 8px 16px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #1A1A1A;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #1F1F1F;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.footer-by {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.footer-version {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
MAIN
|
||||
=============================== */
|
||||
.main {
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px 32px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
animation: fadeIn 0.25s ease both;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
HERO
|
||||
=============================== */
|
||||
.hero {
|
||||
position: relative;
|
||||
border-radius: var(--r-lg);
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-green {
|
||||
background: linear-gradient(135deg, #16893E 0%, #0F3B22 60%, #062614 100%);
|
||||
}
|
||||
|
||||
.hero-blue {
|
||||
background: linear-gradient(135deg, #2D4D7A 0%, #1A2F4A 60%, #0D1A2B 100%);
|
||||
}
|
||||
|
||||
.hero-purple {
|
||||
background: linear-gradient(135deg, #5D2C9C 0%, #2D1B4D 60%, #1A0F2E 100%);
|
||||
}
|
||||
|
||||
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
||||
|
||||
.hero-eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--green);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.hero-eyebrow.blue { color: var(--blue); }
|
||||
.hero-eyebrow.purple { color: var(--purple); }
|
||||
|
||||
.hero-title {
|
||||
font-size: 36px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.8px;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-deco {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 180px;
|
||||
font-weight: 900;
|
||||
opacity: 0.08;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
SECTION LABELS
|
||||
=============================== */
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
margin: 8px 4px 10px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
CARDS
|
||||
=============================== */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--r-md);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 18px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card-accent-purple {
|
||||
background: linear-gradient(180deg, #1F1532 0%, #181818 80%);
|
||||
border: 1px solid rgba(168, 85, 247, 0.2);
|
||||
}
|
||||
|
||||
.card.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
FIELDS
|
||||
=============================== */
|
||||
.field { margin-bottom: 14px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
|
||||
.field.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.field.inline .field-label { margin-bottom: 0; }
|
||||
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row.gap { gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.row.spread { justify-content: space-between; }
|
||||
|
||||
.badge {
|
||||
background: rgba(29, 185, 84, 0.15);
|
||||
color: var(--green);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
INPUTS
|
||||
=============================== */
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid transparent;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.input.pill { border-radius: var(--r-pill); }
|
||||
|
||||
.input::placeholder { color: var(--text-3); }
|
||||
|
||||
.input:focus {
|
||||
background: #333;
|
||||
border-color: rgba(29, 185, 84, 0.4);
|
||||
}
|
||||
|
||||
.input-sm { max-width: 200px; }
|
||||
|
||||
select.input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path fill='%23B3B3B3' d='M2 4l4 4 4-4z'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 14px center;
|
||||
background-size: 12px;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
input[type="number"] { -moz-appearance: textfield; }
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
flex: 1;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.path-display:empty,
|
||||
.path-display.empty { color: var(--text-3); }
|
||||
|
||||
/* ===============================
|
||||
BUTTONS
|
||||
=============================== */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 22px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--r-sm);
|
||||
white-space: nowrap;
|
||||
transition: transform 0.06s ease, background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn.pill { border-radius: var(--r-pill); }
|
||||
.btn.btn-sm { padding: 8px 16px; font-size: 12px; }
|
||||
.btn.btn-lg { padding: 14px 32px; font-size: 14px; }
|
||||
|
||||
.btn:active { transform: scale(0.96); }
|
||||
.btn:disabled { cursor: not-allowed; opacity: 0.4; }
|
||||
.btn:not(:disabled):hover { filter: brightness(1.06); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--green);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--green-hover);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.btn-blue {
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-purple {
|
||||
background: var(--purple);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #00CEC9;
|
||||
color: #000;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
}
|
||||
|
||||
.btn-danger:not(:disabled):hover {
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-ghost:not(:disabled):hover {
|
||||
background: var(--bg-input-hover);
|
||||
}
|
||||
|
||||
.ico {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
COUNTER / PROGRESS
|
||||
=============================== */
|
||||
.counter {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.micro-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-2);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.percent.green { color: var(--green); }
|
||||
.percent.blue { color: var(--blue); }
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s ease-out;
|
||||
}
|
||||
|
||||
.progress-fill.green {
|
||||
background: linear-gradient(90deg, var(--green), var(--green-hover));
|
||||
}
|
||||
|
||||
.progress-fill.blue {
|
||||
background: linear-gradient(90deg, var(--blue), var(--blue-hover));
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
LOG BOX
|
||||
=============================== */
|
||||
.log {
|
||||
background: var(--bg-card-2);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 14px 16px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
height: 220px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
user-select: text;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log .l-ok { color: var(--green); }
|
||||
.log .l-err { color: var(--red); }
|
||||
.log .l-info { color: var(--blue); }
|
||||
.log .l-warn { color: #FDCB6E; }
|
||||
.log .l-muted { color: var(--text-3); }
|
||||
|
||||
/* ===============================
|
||||
CHECKBOX
|
||||
=============================== */
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 6px 0 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox input { display: none; }
|
||||
|
||||
.check-box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--bg-input-hover);
|
||||
position: relative;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.checkbox input:checked + .check-box {
|
||||
background: var(--blue);
|
||||
border-color: var(--blue);
|
||||
}
|
||||
|
||||
.checkbox input:checked + .check-box::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 11px;
|
||||
border: solid #fff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
INFO ROW (Upgrade tab)
|
||||
=============================== */
|
||||
.info-row {
|
||||
color: var(--green);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
SETTINGS
|
||||
=============================== */
|
||||
.version-line {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.strong { color: var(--text); font-weight: 700; }
|
||||
|
||||
.update-status {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-top: 12px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.update-status.ok { color: var(--green); }
|
||||
.update-status.warn { color: #FDCB6E; }
|
||||
.update-status.err { color: var(--red); }
|
||||
|
||||
.update-notes {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 20px 0 32px;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.save-status.show { opacity: 1; }
|
||||
|
||||
/* ===============================
|
||||
MODAL
|
||||
=============================== */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.18s ease both;
|
||||
}
|
||||
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--r-lg);
|
||||
width: min(560px, calc(100% - 40px));
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.modal-head h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-2);
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.modal-close:hover { color: var(--text); }
|
||||
|
||||
.modal-body {
|
||||
padding: 18px 22px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
line-height: 1.6;
|
||||
user-select: text;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-foot {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
TOAST
|
||||
=============================== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 100px);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 14px 22px;
|
||||
border-radius: var(--r-pill);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.2, 0.7, 0.3, 1), opacity 0.3s;
|
||||
z-index: 300;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.toast.toast-error { border-color: var(--red); color: var(--red); }
|
||||
.toast.toast-success { border-color: var(--green); color: var(--green); }
|
||||
|
||||
/* ===============================
|
||||
SCROLLBARS (WebKit)
|
||||
=============================== */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #404040;
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #606060; }
|
||||
|
||||
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
|
||||
.main::-webkit-scrollbar-thumb { border-color: var(--bg); }
|
||||
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MusicDownload</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<!-- ============== SIDEBAR ============== -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">
|
||||
<span>♫</span>
|
||||
</div>
|
||||
<div class="brand-name">MusicDownload</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<nav class="nav">
|
||||
<button class="nav-item active" data-view="download">
|
||||
<span class="nav-icon">⬇</span>
|
||||
<span>Scarica</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="settings">
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span>Impostazioni</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-by">by LuZa</div>
|
||||
<div class="footer-version" id="footerVersion">v1.0</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============== MAIN ============== -->
|
||||
<main class="main">
|
||||
|
||||
<!-- ===== VIEW: DOWNLOAD ===== -->
|
||||
<section class="view active" id="view-download">
|
||||
<header class="hero hero-green">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow">DOWNLOAD</div>
|
||||
<h1 class="hero-title">Scarica la tua musica</h1>
|
||||
<p class="hero-subtitle">Spotify · YouTube · SoundCloud · qualsiasi link supportato da yt-dlp</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
|
||||
<span class="badge" id="urlListBadge" hidden></span>
|
||||
</label>
|
||||
<div class="row">
|
||||
<input type="text" id="urlInput" class="input pill" placeholder="Incolla qui un link Spotify, YouTube o SoundCloud…" />
|
||||
<button class="btn btn-ghost pill" id="loadListBtn">
|
||||
<span>📂</span> Carica lista
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella output</label>
|
||||
<div class="row">
|
||||
<div class="path-display" id="dlPathDisplay">Nessuna cartella selezionata</div>
|
||||
<button class="btn btn-ghost pill btn-sm" id="dlBrowseBtn">Sfoglia</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button class="btn btn-primary pill btn-lg" id="downloadBtn">
|
||||
<span class="ico">▶</span> Avvia Download
|
||||
</button>
|
||||
<button class="btn btn-danger pill btn-lg" id="stopDownloadBtn" disabled>
|
||||
<span class="ico">◼</span> Stop
|
||||
</button>
|
||||
<div class="counter" id="dlCounter"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="progress-head">
|
||||
<span class="micro-label">Progresso</span>
|
||||
<span class="percent green" id="dlPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-fill green" id="dlProgressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="dlLog"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: UPGRADE ===== -->
|
||||
<section class="view" id="view-upgrade">
|
||||
<header class="hero hero-blue">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow blue">UPGRADE QUALITÀ</div>
|
||||
<h1 class="hero-title">Migliora i tuoi audio</h1>
|
||||
<p class="hero-subtitle">Riscarica i file a bitrate più alto. Imposta la soglia minima accettabile.</p>
|
||||
</div>
|
||||
<div class="hero-deco">⚡</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Cartella audio</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<div class="row">
|
||||
<div class="path-display" id="upPathDisplay">Nessuna cartella selezionata</div>
|
||||
<button class="btn btn-ghost pill btn-sm" id="upBrowseBtn">📂 Sfoglia</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="upRecursive" />
|
||||
<span class="check-box"></span>
|
||||
<span>Includi sottocartelle</span>
|
||||
</label>
|
||||
|
||||
<div class="info-row" id="upInfo"></div>
|
||||
|
||||
<div class="field inline">
|
||||
<label class="field-label">Soglia HQ (kbps)</label>
|
||||
<input type="number" id="upThreshold" class="input pill input-sm" value="310" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button class="btn btn-blue pill btn-lg" id="upgradeBtn">
|
||||
<span class="ico">▶</span> Avvia Upgrade
|
||||
</button>
|
||||
<button class="btn btn-danger pill btn-lg" id="stopUpgradeBtn" disabled>
|
||||
<span class="ico">◼</span> Stop
|
||||
</button>
|
||||
<div class="counter" id="upCounter"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="progress-head">
|
||||
<span class="micro-label">Progresso</span>
|
||||
<span class="percent blue" id="upPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-fill blue" id="upProgressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="upLog"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: SETTINGS ===== -->
|
||||
<section class="view" id="view-settings">
|
||||
<header class="hero hero-purple">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow purple">IMPOSTAZIONI</div>
|
||||
<h1 class="hero-title">Configurazione</h1>
|
||||
<p class="hero-subtitle">Credenziali Spotify, qualità audio, percorsi e aggiornamenti.</p>
|
||||
</div>
|
||||
<div class="hero-deco">⚙</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Spotify API</h2>
|
||||
<div class="card card-accent-purple">
|
||||
<div class="row gap">
|
||||
<button class="btn btn-purple pill" id="showGuideBtn">Come ottenere le credenziali?</button>
|
||||
<button class="btn btn-ghost pill" id="openDashboardBtn">Apri Spotify Dashboard</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Client ID</label>
|
||||
<input type="text" id="clientIdInput" class="input pill" placeholder="Inserisci Client ID" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Client Secret</label>
|
||||
<div class="row">
|
||||
<input type="password" id="clientSecretInput" class="input pill" placeholder="Inserisci Client Secret" />
|
||||
<button class="btn btn-ghost pill btn-sm" id="toggleSecretBtn">Mostra</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Download</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Bitrate</label>
|
||||
<select id="bitrateSelect" 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 class="field">
|
||||
<label class="field-label">Soglia HQ (kbps)</label>
|
||||
<input type="number" id="hqThresholdInput" class="input pill input-sm" placeholder="310" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Percorsi</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Percorso cookies.txt</label>
|
||||
<div class="row">
|
||||
<input type="text" id="cookiesInput" class="input pill" placeholder="cookies.txt" />
|
||||
<button class="btn btn-ghost pill btn-sm" id="browseCookiesBtn">Sfoglia</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella output predefinita</label>
|
||||
<div class="row">
|
||||
<input type="text" id="outputInput" class="input pill" placeholder="MUSICA/" />
|
||||
<button class="btn btn-ghost pill btn-sm" id="browseOutputBtn">Sfoglia</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Aspetto</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Tema</label>
|
||||
<select id="themeSelect" class="input pill input-sm">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Aggiornamenti</h2>
|
||||
<div class="card">
|
||||
<div class="row spread">
|
||||
<div class="version-line">
|
||||
Versione corrente: <span class="strong" id="currentVersionLabel">v1.0</span>
|
||||
</div>
|
||||
<button class="btn btn-purple pill" id="checkUpdateBtn">Controlla aggiornamenti</button>
|
||||
</div>
|
||||
<div class="update-status" id="updateStatus"></div>
|
||||
<button class="btn btn-success pill" id="downloadUpdateBtn" hidden>Scarica aggiornamento</button>
|
||||
<div class="update-notes" id="updateNotes"></div>
|
||||
</div>
|
||||
|
||||
<div class="save-row">
|
||||
<button class="btn btn-primary pill btn-lg" id="saveBtn">Salva Impostazioni</button>
|
||||
<div class="save-status" id="saveStatus"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ============== MODAL: Spotify Guide ============== -->
|
||||
<div class="modal-backdrop" id="guideModal" hidden>
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h2>Come ottenere le credenziali Spotify</h2>
|
||||
<button class="modal-close" id="guideCloseBtn">×</button>
|
||||
</div>
|
||||
<pre class="modal-body" id="guideBody"></pre>
|
||||
<div class="modal-foot">
|
||||
<button class="btn btn-purple pill" id="guideOpenDashBtn">Apri Spotify Dashboard</button>
|
||||
<button class="btn btn-ghost pill" id="guideCloseBtn2">Chiudi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== TOAST ============== -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
/* ======================================================
|
||||
MusicDownload — Frontend (vanilla JS)
|
||||
====================================================== */
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
const state = {
|
||||
config: {},
|
||||
urlList: [],
|
||||
dlOutputDir: "",
|
||||
upDir: "",
|
||||
showSecret: false,
|
||||
downloading: false,
|
||||
upgrading: false,
|
||||
};
|
||||
|
||||
// Wait for pywebview ready
|
||||
function waitApi() {
|
||||
return new Promise((resolve) => {
|
||||
if (window.pywebview && window.pywebview.api) return resolve();
|
||||
window.addEventListener("pywebviewready", resolve, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Bridge — eventi spinti dal backend
|
||||
// ============================================================
|
||||
window.bridge = {
|
||||
emit(channel, payload) {
|
||||
const handler = bridgeHandlers[channel];
|
||||
if (handler) handler(payload);
|
||||
},
|
||||
};
|
||||
|
||||
const bridgeHandlers = {
|
||||
"log": ({ view, msg }) => appendLog(view, msg),
|
||||
|
||||
"download:progress": (p) => {
|
||||
if (typeof p.overall === "number") {
|
||||
const pct = Math.round(p.overall * 100);
|
||||
$("#dlProgressFill").style.width = pct + "%";
|
||||
$("#dlPercent").textContent = pct + "%";
|
||||
}
|
||||
if (p.status === "searching" && typeof p.idx === "number") {
|
||||
const prefix = p.url_total > 1 ? `[${(p.url_idx ?? 0) + 1}/${p.url_total}] ` : "";
|
||||
$("#dlCounter").textContent = `${prefix}Brano ${p.idx + 1} di ${p.total}`;
|
||||
}
|
||||
if (p.status === "completed" && p.url_total > 1) {
|
||||
$("#dlCounter").textContent = `Completato: ${p.url_total} URL`;
|
||||
}
|
||||
},
|
||||
|
||||
"download:done": () => {
|
||||
state.downloading = false;
|
||||
state.urlList = [];
|
||||
$("#urlListBadge").hidden = true;
|
||||
$("#urlListBadge").textContent = "";
|
||||
$("#downloadBtn").disabled = false;
|
||||
$("#loadListBtn").disabled = false;
|
||||
$("#stopDownloadBtn").disabled = true;
|
||||
},
|
||||
|
||||
"upgrade:progress": (p) => {
|
||||
if (typeof p.overall === "number") {
|
||||
const pct = Math.round(p.overall * 100);
|
||||
$("#upProgressFill").style.width = pct + "%";
|
||||
$("#upPercent").textContent = pct + "%";
|
||||
}
|
||||
if (typeof p.idx === "number" && typeof p.total === "number" && p.total > 0) {
|
||||
$("#upCounter").textContent = `File ${p.idx} di ${p.total}`;
|
||||
}
|
||||
if (p.status === "completed") {
|
||||
$("#upCounter").textContent = `Completato: ${p.total} file`;
|
||||
}
|
||||
},
|
||||
|
||||
"upgrade:done": () => {
|
||||
state.upgrading = false;
|
||||
$("#upgradeBtn").disabled = false;
|
||||
$("#stopUpgradeBtn").disabled = true;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Logging
|
||||
// ============================================================
|
||||
const logEls = { download: () => $("#dlLog"), upgrade: () => $("#upLog") };
|
||||
|
||||
function classifyLog(msg) {
|
||||
if (msg.startsWith("[ERRORE]")) return "l-err";
|
||||
if (msg.startsWith("[OK]") || msg.startsWith("[UPGRADE]")) return "l-ok";
|
||||
if (msg.startsWith("[INFO]")) return "l-info";
|
||||
if (msg.startsWith("[SKIP]") || msg.startsWith("[CERCA]") || msg.startsWith("[DOWNLOAD]") || msg.startsWith("[CONV]")) return "l-muted";
|
||||
if (msg.startsWith("[NON TROVATO]")) return "l-warn";
|
||||
if (msg.startsWith("[LISTA]")) return "l-info";
|
||||
return "";
|
||||
}
|
||||
|
||||
function appendLog(view, msg) {
|
||||
const el = logEls[view] && logEls[view]();
|
||||
if (!el) return;
|
||||
const cls = classifyLog(msg);
|
||||
const line = document.createElement("div");
|
||||
if (cls) line.className = cls;
|
||||
line.textContent = msg;
|
||||
el.appendChild(line);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toast
|
||||
// ============================================================
|
||||
let toastTimer = null;
|
||||
function toast(msg, kind = "") {
|
||||
const el = $("#toast");
|
||||
el.textContent = msg;
|
||||
el.className = "toast show " + (kind ? `toast-${kind}` : "");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.className = "toast"; }, 3000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Routing (sidebar)
|
||||
// ============================================================
|
||||
function showView(name) {
|
||||
$$(".view").forEach((v) => v.classList.toggle("active", v.id === `view-${name}`));
|
||||
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||||
}
|
||||
|
||||
$$(".nav-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => showView(btn.dataset.view));
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
async function init() {
|
||||
await waitApi();
|
||||
const data = await window.pywebview.api.get_init_data();
|
||||
state.config = data.config || {};
|
||||
$("#footerVersion").textContent = data.version;
|
||||
$("#currentVersionLabel").textContent = data.version;
|
||||
$("#guideBody").textContent = data.spotify_guide;
|
||||
|
||||
// Populate Settings fields
|
||||
$("#clientIdInput").value = state.config.client_id || "";
|
||||
$("#clientSecretInput").value = state.config.client_secret || "";
|
||||
$("#bitrateSelect").value = state.config.bitrate || "320K";
|
||||
$("#hqThresholdInput").value = state.config.hq_threshold || 310;
|
||||
$("#cookiesInput").value = state.config.cookies_path || "";
|
||||
$("#outputInput").value = state.config.output_dir || "";
|
||||
$("#themeSelect").value = state.config.theme || "dark";
|
||||
|
||||
// Download tab — usa output_dir come default
|
||||
state.dlOutputDir = state.config.output_dir || "";
|
||||
if (state.dlOutputDir) {
|
||||
$("#dlPathDisplay").textContent = state.dlOutputDir;
|
||||
$("#dlPathDisplay").classList.remove("empty");
|
||||
}
|
||||
|
||||
// Upgrade tab — default threshold
|
||||
$("#upThreshold").value = state.config.hq_threshold || 310;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOWNLOAD tab
|
||||
// ============================================================
|
||||
$("#dlBrowseBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.browse_directory();
|
||||
if (path) {
|
||||
state.dlOutputDir = path;
|
||||
$("#dlPathDisplay").textContent = path;
|
||||
$("#dlPathDisplay").classList.remove("empty");
|
||||
}
|
||||
});
|
||||
|
||||
$("#loadListBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.browse_file([
|
||||
"Text files (*.txt)",
|
||||
"All files (*.*)",
|
||||
]);
|
||||
if (!path) return;
|
||||
const res = await window.pywebview.api.load_url_list(path);
|
||||
if (!res.ok) {
|
||||
toast("Impossibile leggere il file: " + (res.error || ""), "error");
|
||||
return;
|
||||
}
|
||||
if (!res.count) {
|
||||
toast("Il file non contiene URL validi", "error");
|
||||
return;
|
||||
}
|
||||
state.urlList = res.urls;
|
||||
$("#urlInput").value = path;
|
||||
const badge = $("#urlListBadge");
|
||||
badge.textContent = `· ${res.count} URL caricati`;
|
||||
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;
|
||||
}
|
||||
|
||||
state.downloading = true;
|
||||
$("#downloadBtn").disabled = true;
|
||||
$("#loadListBtn").disabled = true;
|
||||
$("#stopDownloadBtn").disabled = false;
|
||||
$("#dlProgressFill").style.width = "0%";
|
||||
$("#dlPercent").textContent = "0%";
|
||||
|
||||
const res = await window.pywebview.api.start_download({
|
||||
urls,
|
||||
output_dir: state.dlOutputDir,
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["download:done"]();
|
||||
}
|
||||
});
|
||||
|
||||
$("#stopDownloadBtn").addEventListener("click", async () => {
|
||||
await window.pywebview.api.stop_download();
|
||||
$("#stopDownloadBtn").disabled = true;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// UPGRADE tab
|
||||
// ============================================================
|
||||
$("#upBrowseBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.browse_directory();
|
||||
if (path) {
|
||||
state.upDir = path;
|
||||
$("#upPathDisplay").textContent = path;
|
||||
$("#upPathDisplay").classList.remove("empty");
|
||||
await refreshUpgradeScan();
|
||||
}
|
||||
});
|
||||
|
||||
$("#upRecursive").addEventListener("change", refreshUpgradeScan);
|
||||
|
||||
async function refreshUpgradeScan() {
|
||||
if (!state.upDir) return;
|
||||
const res = await window.pywebview.api.scan_audio_folder(state.upDir, $("#upRecursive").checked);
|
||||
const info = $("#upInfo");
|
||||
if (!res.ok) {
|
||||
info.textContent = "Errore scansione: " + (res.error || "");
|
||||
return;
|
||||
}
|
||||
if (!res.total) {
|
||||
info.textContent = "Nessun file audio trovato";
|
||||
return;
|
||||
}
|
||||
if (res.done > 0) {
|
||||
info.textContent = `● ${res.total} file audio · ${res.done} già convertiti · ${res.remaining} da fare`;
|
||||
} else {
|
||||
info.textContent = `● ${res.total} file audio trovati`;
|
||||
}
|
||||
}
|
||||
|
||||
$("#upgradeBtn").addEventListener("click", async () => {
|
||||
if (!state.upDir) {
|
||||
toast("Seleziona una cartella", "error");
|
||||
return;
|
||||
}
|
||||
state.upgrading = true;
|
||||
$("#upgradeBtn").disabled = true;
|
||||
$("#stopUpgradeBtn").disabled = false;
|
||||
$("#upProgressFill").style.width = "0%";
|
||||
$("#upPercent").textContent = "0%";
|
||||
|
||||
const res = await window.pywebview.api.start_upgrade({
|
||||
directory: state.upDir,
|
||||
recursive: $("#upRecursive").checked,
|
||||
threshold: parseInt($("#upThreshold").value, 10) || 310,
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["upgrade:done"]();
|
||||
}
|
||||
});
|
||||
|
||||
$("#stopUpgradeBtn").addEventListener("click", async () => {
|
||||
await window.pywebview.api.stop_upgrade();
|
||||
$("#stopUpgradeBtn").disabled = true;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SETTINGS tab
|
||||
// ============================================================
|
||||
$("#toggleSecretBtn").addEventListener("click", () => {
|
||||
state.showSecret = !state.showSecret;
|
||||
$("#clientSecretInput").type = state.showSecret ? "text" : "password";
|
||||
$("#toggleSecretBtn").textContent = state.showSecret ? "Nascondi" : "Mostra";
|
||||
});
|
||||
|
||||
$("#browseCookiesBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.browse_file([
|
||||
"Text files (*.txt)",
|
||||
"All files (*.*)",
|
||||
]);
|
||||
if (path) $("#cookiesInput").value = path;
|
||||
});
|
||||
|
||||
$("#browseOutputBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.browse_directory();
|
||||
if (path) $("#outputInput").value = path;
|
||||
});
|
||||
|
||||
$("#saveBtn").addEventListener("click", async () => {
|
||||
const payload = {
|
||||
client_id: $("#clientIdInput").value,
|
||||
client_secret: $("#clientSecretInput").value,
|
||||
bitrate: $("#bitrateSelect").value,
|
||||
hq_threshold: parseInt($("#hqThresholdInput").value, 10) || 310,
|
||||
cookies_path: $("#cookiesInput").value,
|
||||
output_dir: $("#outputInput").value,
|
||||
theme: $("#themeSelect").value,
|
||||
};
|
||||
const res = await window.pywebview.api.save_settings(payload);
|
||||
if (res.ok) {
|
||||
state.config = payload;
|
||||
// sync Download tab default path
|
||||
if (!state.dlOutputDir && payload.output_dir) {
|
||||
state.dlOutputDir = payload.output_dir;
|
||||
$("#dlPathDisplay").textContent = payload.output_dir;
|
||||
$("#dlPathDisplay").classList.remove("empty");
|
||||
}
|
||||
const status = $("#saveStatus");
|
||||
status.textContent = "✓ Impostazioni salvate";
|
||||
status.classList.add("show");
|
||||
setTimeout(() => status.classList.remove("show"), 2500);
|
||||
} else {
|
||||
toast("Errore nel salvataggio", "error");
|
||||
}
|
||||
});
|
||||
|
||||
$("#openDashboardBtn").addEventListener("click", () => {
|
||||
window.pywebview.api.open_external_url("https://developer.spotify.com/dashboard");
|
||||
});
|
||||
|
||||
$("#showGuideBtn").addEventListener("click", () => {
|
||||
$("#guideModal").hidden = false;
|
||||
});
|
||||
|
||||
$("#guideCloseBtn").addEventListener("click", () => { $("#guideModal").hidden = true; });
|
||||
$("#guideCloseBtn2").addEventListener("click", () => { $("#guideModal").hidden = true; });
|
||||
$("#guideOpenDashBtn").addEventListener("click", () => {
|
||||
window.pywebview.api.open_external_url("https://developer.spotify.com/dashboard");
|
||||
});
|
||||
|
||||
$("#checkUpdateBtn").addEventListener("click", async () => {
|
||||
const btn = $("#checkUpdateBtn");
|
||||
const status = $("#updateStatus");
|
||||
const notes = $("#updateNotes");
|
||||
const dlBtn = $("#downloadUpdateBtn");
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Controllo...";
|
||||
status.className = "update-status";
|
||||
status.textContent = "";
|
||||
notes.textContent = "";
|
||||
dlBtn.hidden = true;
|
||||
|
||||
const res = await window.pywebview.api.check_update();
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Controlla aggiornamenti";
|
||||
|
||||
if (!res.ok) {
|
||||
status.className = "update-status err";
|
||||
status.textContent = "Errore: " + (res.error || "");
|
||||
return;
|
||||
}
|
||||
if (res.is_new) {
|
||||
status.className = "update-status warn";
|
||||
status.textContent = `Nuova versione disponibile: ${res.remote}`;
|
||||
if (res.download_url) {
|
||||
dlBtn.hidden = false;
|
||||
dlBtn.onclick = () => window.pywebview.api.open_external_url(res.download_url);
|
||||
}
|
||||
if (res.notes) notes.textContent = res.notes;
|
||||
} else {
|
||||
status.className = "update-status ok";
|
||||
status.textContent = "✓ Sei aggiornato!";
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Boot
|
||||
// ============================================================
|
||||
init().catch((e) => {
|
||||
console.error(e);
|
||||
toast("Errore di inizializzazione: " + e.message, "error");
|
||||
});
|
||||
Reference in new issue
Block a user