commit 8c06a26ddbb16efdc9d0f46c9c9044f023bfcd39 Author: luzadev Date: Sat May 30 12:48:58 2026 +0200 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..284c67e --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d68f2d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/PlayList_2026.txt b/PlayList_2026.txt new file mode 100644 index 0000000..39e5e95 --- /dev/null +++ b/PlayList_2026.txt @@ -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 \ No newline at end of file diff --git a/PlayList_da_scaricare.txt b/PlayList_da_scaricare.txt new file mode 100644 index 0000000..8065bcb --- /dev/null +++ b/PlayList_da_scaricare.txt @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d87293b --- /dev/null +++ b/README.md @@ -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. diff --git a/Vibes_List.txt b/Vibes_List.txt new file mode 100644 index 0000000..6d5f0ef --- /dev/null +++ b/Vibes_List.txt @@ -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 \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/bridge.py b/api/bridge.py new file mode 100644 index 0000000..13f1b56 --- /dev/null +++ b/api/bridge.py @@ -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}) diff --git a/build_macos.py b/build_macos.py new file mode 100644 index 0000000..3d8c00d --- /dev/null +++ b/build_macos.py @@ -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() diff --git a/build_windows.py b/build_windows.py new file mode 100644 index 0000000..a032605 --- /dev/null +++ b/build_windows.py @@ -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() diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..1e8708d --- /dev/null +++ b/config.example.json @@ -0,0 +1,9 @@ +{ + "client_id": "", + "client_secret": "", + "bitrate": "320K", + "hq_threshold": 310, + "cookies_path": "", + "output_dir": "", + "theme": "dark" +} diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..ada4987 --- /dev/null +++ b/core/config.py @@ -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) diff --git a/core/downloader.py b/core/downloader.py new file mode 100644 index 0000000..84f2aa6 --- /dev/null +++ b/core/downloader.py @@ -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) diff --git a/core/paths.py b/core/paths.py new file mode 100644 index 0000000..25b71a1 --- /dev/null +++ b/core/paths.py @@ -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 diff --git a/core/spotify_client.py b/core/spotify_client.py new file mode 100644 index 0000000..d662db3 --- /dev/null +++ b/core/spotify_client.py @@ -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 diff --git a/core/upgrader.py b/core/upgrader.py new file mode 100644 index 0000000..b5c6cf8 --- /dev/null +++ b/core/upgrader.py @@ -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 diff --git a/grafica.png b/grafica.png new file mode 100644 index 0000000..40fed8e Binary files /dev/null and b/grafica.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..97ac117 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed571a3 --- /dev/null +++ b/requirements.txt @@ -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" diff --git a/spotify_to_youtube_downloader.sh b/spotify_to_youtube_downloader.sh new file mode 100755 index 0000000..99e60fc --- /dev/null +++ b/spotify_to_youtube_downloader.sh @@ -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 [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" \ No newline at end of file diff --git a/upgrade_quality.sh b/upgrade_quality.sh new file mode 100755 index 0000000..8cf9dd6 --- /dev/null +++ b/upgrade_quality.sh @@ -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 "========================================" diff --git a/webui/css/style.css b/webui/css/style.css new file mode 100644 index 0000000..5632568 --- /dev/null +++ b/webui/css/style.css @@ -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,"); + 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); } diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..9487148 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,291 @@ + + + + + + MusicDownload + + + +
+ + + + + +
+ + +
+
+
+
DOWNLOAD
+

Scarica la tua musica

+

Spotify · YouTube · SoundCloud · qualsiasi link supportato da yt-dlp

+
+
♫
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+
Nessuna cartella selezionata
+ +
+
+
+ +
+ + +
+
+ +
+
+ Progresso + 0% +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
UPGRADE QUALITÀ
+

Migliora i tuoi audio

+

Riscarica i file a bitrate più alto. Imposta la soglia minima accettabile.

+
+
⚡
+
+ + +
+
+
+
Nessuna cartella selezionata
+ +
+
+ + + +
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+ Progresso + 0% +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
IMPOSTAZIONI
+

Configurazione

+

Credenziali Spotify, qualità audio, percorsi e aggiornamenti.

+
+
⚙
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Versione corrente: v1.0 +
+ +
+
+ +
+
+ +
+ +
+
+
+ +
+
+ + + + + +
+ + + + diff --git a/webui/js/app.js b/webui/js/app.js new file mode 100644 index 0000000..824a656 --- /dev/null +++ b/webui/js/app.js @@ -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"); +});