Initial commit: MusicDownload con UI pywebview

- Frontend HTML/CSS/JS in webui/ (stile Spotify-like, hero gradient, modal blur)
- API bridge JS<->Python in api/bridge.py
- Core invariato: downloader yt-dlp, upgrader, spotify client, config
- Build scripts macOS (WebKit) e Windows (EdgeChromium)
- .gitignore esclude credenziali (config.json, cookies.txt) e artefatti build

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
luzadevandClaude Opus 4.7 committed 2026-05-30 12:48:58 +02:00
commit 8c06a26ddb
25 files changed
+4316

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
name: Build MusicDownload
on:
workflow_dispatch: # Lancia manualmente da GitHub
push:
tags:
- 'v*' # Builda automaticamente sui tag (es. v1.0.0)
jobs:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pyinstaller
- name: Install ffmpeg
run: brew install ffmpeg
- name: Build app
run: python build_macos.py
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MusicDownload-macOS
path: dist/MusicDownload.app
compression-level: 9
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pyinstaller
- name: Build app
run: python build_windows.py
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MusicDownload-Windows
path: dist/MusicDownload
compression-level: 9
+36
View File
@@ -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
+7
View File
@@ -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
+18
View File
@@ -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
+132
View File
@@ -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.
+5
View File
@@ -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
View File
Whitespace-only changes.
+390
View File
@@ -0,0 +1,390 @@
"""Ponte JS <-> Python: tutti i metodi che la UI invoca via pywebview.api."""
from __future__ import annotations
import json
import os
import threading
import time
import webbrowser
from typing import Any, Optional
from core.config import load_config, save_config, VERSION
from core.downloader import (
download_playlist,
download_direct_url,
request_stop as request_download_stop,
reset_stop as reset_download_stop,
is_stopped as download_is_stopped,
)
from core.spotify_client import get_access_token, resolve_spotify_url
from core.upgrader import (
upgrade_folder,
request_stop as request_upgrade_stop,
count_files_info,
)
SPOTIFY_GUIDE_TEXT = """\
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
2) Clicca "Create app".
3) Compila:
- App name: MusicDownload (o quello che vuoi)
- Description: a piacere
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
- Seleziona "Web API"
- Accetta i termini, "Save"
4) Apri la app appena creata > "Settings" e copia:
- Client ID
- Client Secret (clicca "View client secret")
5) Incollali qui sotto e premi "Salva".
Note: credenziali gratuite, no Premium. Limite 100 richieste/minuto.
"""
def _js_safe(value: Any) -> str:
"""Serializza un valore per inserirlo in evaluate_js."""
return json.dumps(value, ensure_ascii=False)
class Api:
"""Espone i metodi a JavaScript tramite pywebview js_api."""
def __init__(self):
self.window = None # impostato dopo create_window
self._download_thread: Optional[threading.Thread] = None
self._upgrade_thread: Optional[threading.Thread] = None
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _emit(self, channel: str, payload: dict | str | None = None) -> None:
"""Invia un evento al frontend."""
if not self.window:
return
try:
self.window.evaluate_js(
f"window.bridge && window.bridge.emit({_js_safe(channel)}, {_js_safe(payload)});"
)
except Exception:
pass
def _log(self, view: str, msg: str) -> None:
self._emit("log", {"view": view, "msg": msg})
# ------------------------------------------------------------------
# Bootstrap
# ------------------------------------------------------------------
def get_init_data(self) -> dict:
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
cfg = load_config()
return {
"version": VERSION,
"config": cfg,
"spotify_guide": SPOTIFY_GUIDE_TEXT,
}
# ------------------------------------------------------------------
# Settings
# ------------------------------------------------------------------
def save_settings(self, payload: dict) -> dict:
try:
threshold = int(payload.get("hq_threshold", 310))
except (TypeError, ValueError):
threshold = 310
config = {
"client_id": (payload.get("client_id") or "").strip(),
"client_secret": (payload.get("client_secret") or "").strip(),
"bitrate": payload.get("bitrate", "320K"),
"hq_threshold": threshold,
"cookies_path": (payload.get("cookies_path") or "").strip(),
"output_dir": (payload.get("output_dir") or "").strip(),
"theme": payload.get("theme", "dark"),
}
save_config(config)
return {"ok": True}
def check_update(self) -> dict:
"""Controlla aggiornamenti chiamando version.json remoto."""
import urllib.request
UPDATE_URL = "https://www.djluza.com/musicdownload/version.json"
try:
req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "MusicDownload"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
remote = data.get("version", "")
return {
"ok": True,
"current": VERSION,
"remote": remote,
"is_new": bool(remote and remote != VERSION),
"download_url": data.get("download_url", ""),
"notes": data.get("notes", ""),
}
except Exception as e:
return {"ok": False, "error": str(e), "current": VERSION}
# ------------------------------------------------------------------
# File dialogs (delegati a pywebview)
# ------------------------------------------------------------------
def _folder_dialog_type(self):
import webview
# API nuova (>= 5.x): webview.FileDialog.FOLDER
# API vecchia: webview.FOLDER_DIALOG
if hasattr(webview, "FileDialog"):
return webview.FileDialog.FOLDER
return webview.FOLDER_DIALOG
def _open_dialog_type(self):
import webview
if hasattr(webview, "FileDialog"):
return webview.FileDialog.OPEN
return webview.OPEN_DIALOG
def browse_directory(self) -> str:
if not self.window:
return ""
result = self.window.create_file_dialog(self._folder_dialog_type())
if result:
return result[0] if isinstance(result, (list, tuple)) else result
return ""
def browse_file(self, filetypes: Optional[list] = None) -> str:
if not self.window:
return ""
if filetypes:
ft = tuple(filetypes)
else:
ft = ("Text files (*.txt)", "All files (*.*)")
result = self.window.create_file_dialog(self._open_dialog_type(), file_types=ft)
if result:
return result[0] if isinstance(result, (list, tuple)) else result
return ""
def load_url_list(self, path: str) -> dict:
try:
with open(path, "r", encoding="utf-8") as f:
urls = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
return {"ok": True, "urls": urls, "count": len(urls)}
except Exception as e:
return {"ok": False, "error": str(e)}
def open_external_url(self, url: str) -> None:
webbrowser.open(url)
# ------------------------------------------------------------------
# Audio folder scan (Upgrade tab)
# ------------------------------------------------------------------
def scan_audio_folder(self, directory: str, recursive: bool) -> dict:
try:
total, done = count_files_info(directory, bool(recursive))
return {"ok": True, "total": total, "done": done, "remaining": total - done}
except Exception as e:
return {"ok": False, "error": str(e)}
# ------------------------------------------------------------------
# DOWNLOAD
# ------------------------------------------------------------------
def start_download(self, payload: dict) -> dict:
if self._download_thread and self._download_thread.is_alive():
return {"ok": False, "error": "Download gia in corso"}
urls = payload.get("urls") or []
output_dir = (payload.get("output_dir") or "").strip()
if not urls:
return {"ok": False, "error": "Nessun URL fornito"}
if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"}
self._download_thread = threading.Thread(
target=self._download_worker,
args=(list(urls), output_dir),
daemon=True,
)
self._download_thread.start()
return {"ok": True}
def stop_download(self) -> dict:
request_download_stop()
self._log("download", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _download_worker(self, urls: list, output_dir: str) -> None:
reset_download_stop()
cfg = load_config()
bitrate = cfg.get("bitrate", "320K")
cookies_path = cfg.get("cookies_path", "")
total_urls = len(urls)
multi = total_urls > 1
view = "download"
token = None
has_spotify = any("spotify.com" in u for u in urls)
if has_spotify:
client_id = cfg.get("client_id", "")
client_secret = cfg.get("client_secret", "")
if not client_id or not client_secret:
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
self._emit("download:done", {"ok": False})
return
self._log(view, "[INFO] Autenticazione Spotify...")
try:
token = get_access_token(client_id, client_secret)
except Exception as e:
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
self._emit("download:done", {"ok": False})
return
for url_idx, url in enumerate(urls):
if download_is_stopped():
self._log(view, "[INFO] Download interrotto.")
break
if multi:
self._log(view, f"\n{'='*50}")
self._log(view, f"[LISTA] URL {url_idx + 1} di {total_urls}")
self._log(view, f"{'='*50}")
is_spotify = "spotify.com" in url
_last = [0.0]
_THROTTLE = 0.10
url_base = url_idx / total_urls if multi else 0
url_weight = 1 / total_urls if multi else 1
def progress_cb(idx, total, track_name, status, pct,
_base=url_base, _weight=url_weight, _uidx=url_idx):
if status == "downloading":
now = time.monotonic()
if now - _last[0] < _THROTTLE:
return
_last[0] = now
payload_evt = {
"idx": idx, "total": total, "track": track_name,
"status": status, "pct": pct,
"url_idx": _uidx, "url_total": total_urls,
}
if status == "searching":
self._log(view, f"[CERCA] {track_name}")
elif status == "skipped":
self._log(view, f"[SKIP] {track_name} (gia scaricato)")
track_progress = (idx + 1) / total if total > 0 else 1
payload_evt["overall"] = min(_base + track_progress * _weight, 1.0)
elif status == "downloading":
track_progress = (idx / total) + (pct / 100 / total) if total > 0 else 0
payload_evt["overall"] = min(_base + track_progress * _weight, 1.0)
elif status == "done":
self._log(view, f"[OK] {track_name}")
track_progress = (idx + 1) / total if total > 0 else 1
payload_evt["overall"] = min(_base + track_progress * _weight, 1.0)
elif status == "stopped":
self._log(view, "[INFO] Download interrotto.")
elif status == "completed":
if not multi:
payload_evt["overall"] = 1.0
self._log(view, "[INFO] Download completato!")
elif status.startswith("error"):
self._log(view, f"[ERRORE] {track_name}: {status}")
self._emit("download:progress", payload_evt)
if is_spotify:
self._log(view, "[INFO] Recupero informazioni da Spotify...")
try:
label, name, tracks = resolve_spotify_url(token, url)
except Exception as e:
self._log(view, f"[ERRORE] {e}")
continue
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
dest_dir = os.path.join(output_dir, name)
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
else:
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
download_direct_url(url, output_dir, bitrate, cookies_path, progress_cb)
if multi and not download_is_stopped():
self._log(view, f"\n[INFO] Completate tutte le {total_urls} playlist/URL!")
self._emit("download:progress", {
"status": "completed", "overall": 1.0,
"url_total": total_urls,
})
self._emit("download:done", {"ok": True})
# ------------------------------------------------------------------
# UPGRADE
# ------------------------------------------------------------------
def start_upgrade(self, payload: dict) -> dict:
if self._upgrade_thread and self._upgrade_thread.is_alive():
return {"ok": False, "error": "Upgrade gia in corso"}
directory = (payload.get("directory") or "").strip()
recursive = bool(payload.get("recursive", False))
try:
threshold = int(payload.get("threshold", 310))
except (TypeError, ValueError):
threshold = 310
if not directory:
return {"ok": False, "error": "Cartella non impostata"}
cfg = load_config()
cookies_path = cfg.get("cookies_path", "")
self._upgrade_thread = threading.Thread(
target=self._upgrade_worker,
args=(directory, threshold, cookies_path, recursive),
daemon=True,
)
self._upgrade_thread.start()
return {"ok": True}
def stop_upgrade(self) -> dict:
request_upgrade_stop()
self._log("upgrade", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _upgrade_worker(self, directory, threshold, cookies_path, recursive):
view = "upgrade"
def progress_cb(idx, total, filename, status, old_kbps, new_kbps):
payload_evt = {
"idx": idx, "total": total, "filename": filename,
"status": status, "old_kbps": old_kbps, "new_kbps": new_kbps,
}
if total > 0:
payload_evt["overall"] = min(idx / total, 1.0)
if status == "searching":
self._log(view, f"[CERCA] {filename} ({old_kbps}kbps)")
elif status == "not_found":
self._log(view, f"[NON TROVATO] {filename}")
elif status == "cover_only":
self._log(view, f"[COPERTINA] {filename} (gia {old_kbps}kbps)")
elif status == "cover_done":
self._log(view, f"[OK COPERTINA] {filename}")
elif status == "cover_failed":
self._log(view, f"[SKIP] {filename} - copertina non disponibile ({old_kbps}kbps ok)")
elif status == "downloading":
self._log(view, f"[DOWNLOAD] {filename} ({old_kbps}kbps)")
elif status == "upgraded":
diff = new_kbps - old_kbps
diff_str = f" (+{diff}kbps)" if diff > 0 else ""
self._log(view, f"[UPGRADE] {filename}: {old_kbps} -> {new_kbps}kbps{diff_str}")
elif status == "download_error":
self._log(view, f"[ERRORE] {filename}: download fallito")
elif status == "stopped":
self._log(view, "[INFO] Upgrade interrotto.")
elif status == "completed":
payload_evt["overall"] = 1.0
self._log(view, "[INFO] Upgrade completato!")
self._emit("upgrade:progress", payload_evt)
upgrade_folder(directory, threshold, cookies_path, recursive, progress_cb)
self._emit("upgrade:done", {"ok": True})
+284
View File
@@ -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()
+179
View File
@@ -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()
+9
View File
@@ -0,0 +1,9 @@
{
"client_id": "",
"client_secret": "",
"bitrate": "320K",
"hq_threshold": 310,
"cookies_path": "",
"output_dir": "",
"theme": "dark"
}
View File
Whitespace-only changes.
+64
View File
@@ -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)
+395
View File
@@ -0,0 +1,395 @@
"""Download da YouTube via yt-dlp (subprocess per usare la versione piu aggiornata)."""
from __future__ import annotations
import json
import re
import subprocess
import threading
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir
# Flag globale per interruzione
_stop_event = threading.Event()
_current_process: Optional[subprocess.Popen] = None
_process_lock = threading.Lock()
def request_stop():
"""Richiede l'interruzione del download in corso."""
_stop_event.set()
with _process_lock:
if _current_process and _current_process.poll() is None:
_current_process.terminate()
def reset_stop():
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
def _load_done_set(done_file: Path) -> set[str]:
"""Carica l'elenco delle tracce gia scaricate."""
if done_file.exists():
try:
return set(done_file.read_text(encoding="utf-8").splitlines())
except OSError:
return set()
return set()
def _mark_done(done_file: Path, key: str) -> None:
"""Aggiunge una traccia al file delle tracce scaricate."""
try:
with open(done_file, "a", encoding="utf-8") as f:
f.write(key + "\n")
except OSError:
pass
_STOPWORDS = {
"official", "video", "audio", "music", "lyric", "lyrics", "hd", "4k",
"remix", "edit", "extended", "feat", "ft", "featuring", "with",
"from", "the", "and", "of", "a", "an", "to", "in",
}
def _tokenize(s: str) -> set[str]:
"""Estrae token alfanumerici significativi da una stringa."""
s = s.lower()
tokens = re.findall(r"[a-z0-9]+", s)
return {t for t in tokens if len(t) >= 2 and t not in _STOPWORDS}
def _scan_existing_files(directory: Path, extensions: tuple[str, ...] = (".mp3", ".m4a", ".opus", ".webm", ".wav", ".flac")) -> list[set[str]]:
"""Ritorna lista di set di token dei filename presenti nella directory."""
if not directory.exists():
return []
return [_tokenize(f.stem) for f in directory.iterdir()
if f.is_file() and f.suffix.lower() in extensions]
def _file_exists_for_query(query: str, existing_tokens: list[set[str]]) -> bool:
"""Verifica se esiste gia un file il cui titolo contiene tutte le parole del query.
Match basato sui token: tutte le parole significative del query devono essere
presenti nel filename (l'ordine non conta, eventuali parole extra nel filename ok)."""
query_tokens = _tokenize(query)
if not query_tokens:
return False
for file_tokens in existing_tokens:
if query_tokens.issubset(file_tokens):
return True
return False
def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str, str]:
"""Cerca un video su YouTube. Ritorna (video_url, video_title)."""
ytdlp = find_ytdlp()
cmd = [
ytdlp,
f"ytsearch1:{query}",
"--dump-json",
"--no-download",
"--no-warnings",
"--flat-playlist",
]
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
data = json.loads(result.stdout)
video_id = data.get("id", "")
title = data.get("title", query)
if not video_id:
raise ValueError("Nessun risultato trovato")
return f"https://www.youtube.com/watch?v={video_id}", title
def download_playlist(
tracks: list[dict],
output_dir: str,
bitrate: str = "320K",
cookies_path: Optional[str] = None,
progress_callback: Optional[Callable] = None,
) -> None:
"""Scarica tutti i brani dalla lista di tracce.
Args:
tracks: lista di {"name": ..., "artist": ...}
output_dir: directory di destinazione
bitrate: qualita audio (es. "320K")
cookies_path: percorso file cookies (opzionale)
progress_callback: callback(track_index, total, track_name, status, percent)
"""
reset_stop()
total = len(tracks)
out_path = Path(output_dir)
out_path.mkdir(parents=True, exist_ok=True)
done_file = out_path / ".downloaded_tracks"
done_set = _load_done_set(done_file)
existing_files = _scan_existing_files(out_path)
ytdlp = find_ytdlp()
for i, track in enumerate(tracks):
if is_stopped():
if progress_callback:
progress_callback(i, total, "", "stopped", 0)
return
query = f"{track['name']} - {track['artist']}"
track_only = track['name']
# Salta brani gia scaricati in precedenza (file di tracking)
if query in done_set:
if progress_callback:
progress_callback(i, total, query, "skipped", 100)
continue
# Salta se esiste gia un file corrispondente nella cartella
if (_file_exists_for_query(query, existing_files)
or _file_exists_for_query(track_only, existing_files)):
_mark_done(done_file, query)
done_set.add(query)
if progress_callback:
progress_callback(i, total, query, "skipped", 100)
continue
if progress_callback:
progress_callback(i, total, query, "searching", 0)
# Cerca il video su YouTube
try:
video_url, _ = _search_youtube(query, cookies_path)
except Exception as e:
if progress_callback:
progress_callback(i, total, query, f"error: {e}", 0)
continue
if is_stopped():
return
if progress_callback:
progress_callback(i, total, query, "downloading", 0)
# Scarica con yt-dlp subprocess
cmd = [
ytdlp,
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", bitrate.rstrip("Kk"),
"--embed-thumbnail",
"--add-metadata",
"--no-check-certificates",
"--newline",
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
video_url,
]
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
try:
with _process_lock:
_current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
for line in _current_process.stdout:
if is_stopped():
_current_process.terminate()
if progress_callback:
progress_callback(i, total, query, "stopped", 0)
return
# Parse progress da output yt-dlp
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
if pct_match and progress_callback:
pct = int(float(pct_match.group(1)))
progress_callback(i, total, query, "downloading", pct)
return_code = _current_process.wait()
with _process_lock:
_current_process = None
if return_code == 0:
_mark_done(done_file, query)
done_set.add(query)
existing_files = _scan_existing_files(out_path)
if progress_callback:
progress_callback(i, total, query, "done", 100)
else:
if progress_callback:
progress_callback(i, total, query, f"error: yt-dlp exit {return_code}", 0)
except Exception as e:
with _process_lock:
_current_process = None
if progress_callback:
progress_callback(i, total, query, f"error: {e}", 0)
if progress_callback and not is_stopped():
progress_callback(total, total, "", "completed", 100)
def download_direct_url(
url: str,
output_dir: str,
bitrate: str = "320K",
cookies_path: Optional[str] = None,
progress_callback: Optional[Callable] = None,
) -> None:
"""Scarica direttamente da un URL supportato da yt-dlp (YouTube, SoundCloud, ecc.).
Supporta sia singoli video che playlist.
Callback signature: progress_callback(idx, total, track_name, status, pct)
"""
global _current_process
reset_stop()
out_path = Path(output_dir)
out_path.mkdir(parents=True, exist_ok=True)
done_file = out_path / ".downloaded_tracks"
done_set = _load_done_set(done_file)
existing_files = _scan_existing_files(out_path)
ytdlp = find_ytdlp()
# 1. Ottieni info tramite yt-dlp --dump-json
probe_cmd = [
ytdlp,
"--dump-json",
"--flat-playlist",
"--no-download",
"--no-warnings",
url,
]
if cookies_path and Path(cookies_path).exists():
probe_cmd.extend(["--cookies", cookies_path])
try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
raise ValueError(result.stderr.strip())
except Exception as e:
if progress_callback:
progress_callback(0, 0, url, f"error: {e}", 0)
return
# Ogni riga stdout e un JSON (una entry per playlist, una per singolo)
entries = []
for line in result.stdout.strip().splitlines():
if line.strip():
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
if not entries:
if progress_callback:
progress_callback(0, 0, url, "error: Nessun contenuto trovato", 0)
return
total = len(entries)
# 2. Scarica ogni entry
for i, entry in enumerate(entries):
if is_stopped():
if progress_callback:
progress_callback(i, total, "", "stopped", 0)
return
title = entry.get("title", f"Track {i + 1}")
entry_url = entry.get("url") or entry.get("webpage_url") or entry.get("original_url") or url
# Per singoli video (non flat-playlist), usa l'URL originale
if total == 1:
entry_url = url
# Salta se gia scaricato (file di tracking)
track_key = entry.get("id") or title
if track_key in done_set:
if progress_callback:
progress_callback(i, total, title, "skipped", 100)
continue
# Salta se esiste gia un file corrispondente nella cartella
if _file_exists_for_query(title, existing_files):
_mark_done(done_file, track_key)
done_set.add(track_key)
if progress_callback:
progress_callback(i, total, title, "skipped", 100)
continue
if progress_callback:
progress_callback(i, total, title, "downloading", 0)
cmd = [
ytdlp,
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", bitrate.rstrip("Kk"),
"--embed-thumbnail",
"--add-metadata",
"--no-check-certificates",
"--newline",
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
entry_url,
]
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
try:
with _process_lock:
_current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
for line in _current_process.stdout:
if is_stopped():
_current_process.terminate()
if progress_callback:
progress_callback(i, total, title, "stopped", 0)
return
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
if pct_match and progress_callback:
pct = int(float(pct_match.group(1)))
progress_callback(i, total, title, "downloading", pct)
return_code = _current_process.wait()
with _process_lock:
_current_process = None
if return_code == 0:
_mark_done(done_file, track_key)
done_set.add(track_key)
existing_files = _scan_existing_files(out_path)
if progress_callback:
progress_callback(i, total, title, "done", 100)
else:
if progress_callback:
progress_callback(i, total, title, f"error: yt-dlp exit {return_code}", 0)
except Exception as e:
with _process_lock:
_current_process = None
if progress_callback:
progress_callback(i, total, title, f"error: {e}", 0)
if progress_callback and not is_stopped():
progress_callback(total, total, "", "completed", 100)
+137
View File
@@ -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
+177
View File
@@ -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
+359
View File
@@ -0,0 +1,359 @@
"""Logica upgrade qualita audio — riscrittura di upgrade_quality.sh."""
from __future__ import annotations
import json
import re
import subprocess
import threading
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
# Flag globale per interruzione
_stop_event = threading.Event()
_current_process: Optional[subprocess.Popen] = None
_process_lock = threading.Lock()
def request_stop():
_stop_event.set()
with _process_lock:
if _current_process and _current_process.poll() is None:
_current_process.terminate()
def reset_stop():
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
def scan_audio_files(directory: str, recursive: bool = False) -> list[Path]:
"""Trova file audio nella directory."""
base = Path(directory)
files = []
if recursive:
for ext in AUDIO_EXTENSIONS:
files.extend(base.rglob(f"*{ext}"))
else:
for ext in AUDIO_EXTENSIONS:
files.extend(base.glob(f"*{ext}"))
return sorted(files)
def get_bitrate(filepath: str | Path) -> int:
"""Ritorna il bitrate in kbps tramite ffprobe. Ritorna 0 in caso di errore."""
try:
ffprobe_bin = find_ffprobe() or "ffprobe"
result = subprocess.run(
[
ffprobe_bin, "-v", "quiet",
"-print_format", "json",
"-show_format",
str(filepath),
],
capture_output=True,
text=True,
timeout=30,
)
data = json.loads(result.stdout)
bit_rate = int(data.get("format", {}).get("bit_rate", 0))
return bit_rate // 1000
except Exception:
return 0
def _load_done_set(done_file: Path) -> set[str]:
if done_file.exists():
return set(done_file.read_text(encoding="utf-8").splitlines())
return set()
def _mark_done(done_file: Path, filename: str) -> None:
with open(done_file, "a", encoding="utf-8") as f:
f.write(filename + "\n")
def _search_youtube(query: str, cookies_path: Optional[str] = None) -> str:
"""Cerca un video su YouTube e ritorna l'URL."""
ytdlp = find_ytdlp()
cmd = [
ytdlp,
f"ytsearch1:{query}",
"--dump-json",
"--no-download",
"--no-warnings",
"--flat-playlist",
]
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise ValueError("Ricerca fallita")
data = json.loads(result.stdout)
video_id = data.get("id", "")
if not video_id:
raise ValueError("Nessun risultato")
return f"https://www.youtube.com/watch?v={video_id}"
def update_cover_only(
filepath: Path,
video_url: str,
temp_dir: Path,
cookies_path: Optional[str] = None,
) -> bool:
"""Aggiorna solo la copertina di un file audio. Ritorna True se riuscito."""
ytdlp = find_ytdlp()
cmd = [
ytdlp,
"--skip-download",
"--write-thumbnail",
"--convert-thumbnails", "jpg",
"--no-warnings",
"--output", str(temp_dir / "cover"),
video_url,
]
ffmpeg_dir = _find_ffmpeg_dir()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
subprocess.run(cmd, capture_output=True, timeout=60)
cover_files = list(temp_dir.glob("cover*.jpg"))
if not cover_files:
return False
cover_file = cover_files[0]
temp_output = temp_dir / "temp_output.mp3"
try:
subprocess.run(
[
"ffmpeg", "-y",
"-i", str(filepath),
"-i", str(cover_file),
"-map", "0:a", "-map", "1:0",
"-c:a", "copy",
"-id3v2_version", "3",
"-metadata:s:v", "title=Album cover",
"-metadata:s:v", "comment=Cover (front)",
str(temp_output),
],
capture_output=True,
timeout=60,
)
except Exception:
_cleanup_temp(temp_dir)
return False
if temp_output.exists():
temp_output.replace(filepath)
_cleanup_temp(temp_dir)
return True
_cleanup_temp(temp_dir)
return False
def _cleanup_temp(temp_dir: Path) -> None:
for pattern in ("*.jpg", "*.webp", "*.mp3", "*.webm", "*.m4a"):
for f in temp_dir.glob(pattern):
f.unlink(missing_ok=True)
def upgrade_folder(
directory: str,
threshold: int = 310,
cookies_path: Optional[str] = None,
recursive: bool = False,
progress_callback: Optional[Callable] = None,
) -> None:
"""Logica principale di upgrade qualita."""
reset_stop()
ytdlp = find_ytdlp()
if recursive:
base = Path(directory)
folders = set()
for ext in AUDIO_EXTENSIONS:
for f in base.rglob(f"*{ext}"):
folders.add(f.parent)
folders = sorted(folders)
else:
folders = [Path(directory)]
all_items: list[tuple[Path, Path]] = []
for folder in folders:
for ext in AUDIO_EXTENSIONS:
for f in sorted(folder.glob(f"*{ext}")):
all_items.append((f, folder))
total = len(all_items)
if total == 0:
if progress_callback:
progress_callback(0, 0, "", "no_files", 0, 0)
return
processed = 0
for filepath, folder in all_items:
if is_stopped():
if progress_callback:
progress_callback(processed, total, "", "stopped", 0, 0)
return
done_file = folder / ".upgraded_tracks"
temp_dir = folder / ".temp_download"
temp_dir.mkdir(exist_ok=True)
done_set = _load_done_set(done_file)
filename = filepath.stem
if filename in done_set:
processed += 1
if progress_callback:
progress_callback(processed, total, filepath.name, "skipped", 0, 0)
continue
current_kbps = get_bitrate(filepath)
if progress_callback:
progress_callback(processed, total, filepath.name, "searching", current_kbps, 0)
query = filename
try:
video_url = _search_youtube(query, cookies_path)
except Exception:
_mark_done(done_file, filename)
processed += 1
if progress_callback:
progress_callback(processed, total, filepath.name, "not_found", current_kbps, 0)
continue
if is_stopped():
return
# Se qualita gia alta, aggiorna solo copertina
if current_kbps >= threshold:
if progress_callback:
progress_callback(processed, total, filepath.name, "cover_only", current_kbps, 0)
success = update_cover_only(filepath, video_url, temp_dir, cookies_path)
_mark_done(done_file, filename)
processed += 1
status = "cover_done" if success else "cover_failed"
if progress_callback:
progress_callback(processed, total, filepath.name, status, current_kbps, current_kbps)
continue
# Scarica versione HQ
if progress_callback:
progress_callback(processed, total, filepath.name, "downloading", current_kbps, 0)
cmd = [
ytdlp,
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", "0",
"--embed-thumbnail",
"--add-metadata",
"--no-warnings",
"--newline",
"--output", str(temp_dir / "%(title)s.%(ext)s"),
video_url,
]
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path])
try:
with _process_lock:
_current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
for line in _current_process.stdout:
if is_stopped():
_current_process.terminate()
return
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
if pct_match and progress_callback:
pct = int(float(pct_match.group(1)))
progress_callback(processed, total, filepath.name, "downloading", current_kbps, pct)
_current_process.wait()
with _process_lock:
_current_process = None
except Exception:
with _process_lock:
_current_process = None
_mark_done(done_file, filename)
processed += 1
if progress_callback:
progress_callback(processed, total, filepath.name, "download_error", current_kbps, 0)
_cleanup_temp(temp_dir)
continue
# Trova il file scaricato
new_files = list(temp_dir.glob("*.mp3"))
if not new_files:
_mark_done(done_file, filename)
processed += 1
if progress_callback:
progress_callback(processed, total, filepath.name, "download_error", current_kbps, 0)
_cleanup_temp(temp_dir)
continue
newest = max(new_files, key=lambda p: p.stat().st_mtime)
new_kbps = get_bitrate(newest)
filepath.unlink()
newest.replace(filepath.parent / f"{filename}.mp3")
_mark_done(done_file, filename)
_cleanup_temp(temp_dir)
processed += 1
if progress_callback:
progress_callback(processed, total, filepath.name, "upgraded", current_kbps, new_kbps)
for folder in folders:
td = folder / ".temp_download"
if td.exists():
try:
td.rmdir()
except OSError:
pass
if progress_callback and not is_stopped():
progress_callback(total, total, "", "completed", 0, 0)
def count_files_info(directory: str, recursive: bool = False) -> tuple[int, int]:
"""Ritorna (total_audio_files, already_upgraded)."""
files = scan_audio_files(directory, recursive)
total = len(files)
already_done = 0
for f in files:
done_file = f.parent / ".upgraded_tracks"
done_set = _load_done_set(done_file)
if f.stem in done_set:
already_done += 1
return total, already_done
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

+42
View File
@@ -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()
+7
View File
@@ -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"
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# ============================================
# 🎵 Spotify → YouTube Downloader (API edition)
# by LuZa / GPT-5
# ============================================
# Requisiti: yt-dlp, ffmpeg, jq, curl
# Installazione rapida (macOS):
# brew install yt-dlp ffmpeg jq curl
# ====== CONFIGURAZIONE ======
CLIENT_ID="379c38c32a5b43259cf55125e6efd472"
CLIENT_SECRET="e58c9a468b624eb3bdd27f701fef3aa2"
BITRATE="320K"
COOKIE_FILE="cookies.txt"
LOGFILE="download.log"
# ====== INPUT ======
if [ -z "$1" ]; then
echo "Uso: $0 <link_playlist_spotify> [directory_output]"
exit 1
fi
SPOTIFY_URL="$1"
CUSTOM_DIR="$2"
# ====== SCELTA DIRECTORY ======
if [ -z "$CUSTOM_DIR" ]; then
echo "📁 Inserisci il percorso della cartella di destinazione:"
read -r CUSTOM_DIR
fi
mkdir -p "$CUSTOM_DIR" || { echo "❌ Impossibile creare la directory $CUSTOM_DIR"; exit 1; }
# ====== OTTIENI TOKEN ACCESSO ======
echo "🔑 Recupero token di accesso Spotify..."
ACCESS_TOKEN=$(curl -s -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials https://accounts.spotify.com/api/token \
| jq -r '.access_token')
if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then
echo "❌ Errore durante il recupero del token Spotify."
exit 1
fi
# ====== ESTRAI ID PLAYLIST ======
PLAYLIST_ID=$(echo "$SPOTIFY_URL" | awk -F'playlist/' '{print $2}' | cut -d'?' -f1)
PLAYLIST_API="https://api.spotify.com/v1/playlists/$PLAYLIST_ID"
# ====== OTTIENI INFO PLAYLIST ======
PLAYLIST_NAME=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$PLAYLIST_API" | jq -r '.name')
if [ "$PLAYLIST_NAME" = "null" ] || [ -z "$PLAYLIST_NAME" ]; then
echo "❌ Impossibile leggere la playlist. Controlla il link o le credenziali."
exit 1
fi
DEST_DIR="$CUSTOM_DIR/$PLAYLIST_NAME"
mkdir -p "$DEST_DIR"
echo "🎧 Playlist: $PLAYLIST_NAME"
echo "📂 Download in: $DEST_DIR"
echo "------------------------------------------"
echo "📝 Log: $DEST_DIR/$LOGFILE"
# ====== OTTIENI TUTTI I BRANI (paginazione API) ======
OFFSET=0
LIMIT=100
while :; do
TRACKS_JSON=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$PLAYLIST_API/tracks?limit=$LIMIT&offset=$OFFSET")
ITEMS=$(echo "$TRACKS_JSON" | jq -r '.items[]?.track.name + " - " + (.items[]?.track.artists[0].name // "Unknown")')
if [ -z "$ITEMS" ]; then
break
fi
echo "$TRACKS_JSON" | jq -r '.items[] | "\(.track.name) - \(.track.artists[0].name)"' >> "$DEST_DIR/tracks.txt"
NEXT=$(echo "$TRACKS_JSON" | jq -r '.next')
[ "$NEXT" = "null" ] && break
OFFSET=$((OFFSET + LIMIT))
done
# ====== SCARICA I BRANI ======
INDEX=0
while IFS= read -r QUERY; do
((INDEX++))
echo "🔍 [$INDEX] Cerco su YouTube: $QUERY"
VIDEO_ID=$(yt-dlp "ytsearch1:$QUERY" --get-id 2>/dev/null | head -n 1)
if [ -z "$VIDEO_ID" ]; then
echo "❌ Non trovato: $QUERY" | tee -a "$DEST_DIR/$LOGFILE"
continue
fi
VIDEO_URL="https://www.youtube.com/watch?v=$VIDEO_ID"
echo "🎬 Trovato: $VIDEO_URL"
yt-dlp \
--extract-audio \
--audio-format mp3 \
--audio-quality "$BITRATE" \
--embed-thumbnail \
--add-metadata \
--no-check-certificates \
--cookies "$COOKIE_FILE" \
--output "$DEST_DIR/%(title)s.%(ext)s" \
"$VIDEO_URL" >>"$DEST_DIR/$LOGFILE" 2>&1
echo "✅ Scaricato: $QUERY"
echo "------------------------------------------"
done < "$DEST_DIR/tracks.txt"
echo "🏁 Download completato!"
echo "📂 File salvati in: $DEST_DIR"
+428
View File
@@ -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 "========================================"
+778
View File
@@ -0,0 +1,778 @@
/* ===============================
MusicDownload — Spotify-like UI
=============================== */
:root {
--bg: #0A0A0A;
--bg-elev: #121212;
--bg-card: #181818;
--bg-card-2: #1F1F1F;
--bg-input: #2A2A2A;
--bg-input-hover: #3A3A3A;
--border: #232323;
--divider: #1F1F1F;
--green: #1DB954;
--green-hover: #1ED760;
--green-press: #169B46;
--green-dim: #0F3B22;
--blue: #509BF5;
--blue-hover: #6FB1F7;
--blue-dim: #1A2F4A;
--purple: #A855F7;
--purple-hover: #C084FC;
--purple-dim: #2D1B4D;
--red: #E22134;
--red-hover: #FF4055;
--text: #FFFFFF;
--text-2: #B3B3B3;
--text-3: #727272;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 14px;
--r-pill: 999px;
--shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
user-select: none;
}
button, input, select, textarea { font-family: inherit; font-size: inherit; }
/* ===============================
LAYOUT
=============================== */
.app {
display: grid;
grid-template-columns: 240px 1fr;
height: 100vh;
}
/* ===============================
SIDEBAR
=============================== */
.sidebar {
background: var(--bg-elev);
display: flex;
flex-direction: column;
padding: 24px 12px;
border-right: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 8px;
margin-bottom: 20px;
}
.brand-logo {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, var(--green), var(--green-press));
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 900;
color: #000;
box-shadow: 0 4px 12px rgba(29, 185, 84, 0.35);
}
.brand-name {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.2px;
}
.divider {
height: 1px;
background: var(--divider);
margin: 0 8px 16px;
}
.nav {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: none;
background: transparent;
color: var(--text-2);
cursor: pointer;
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 700;
text-align: left;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-item:hover {
background: #1A1A1A;
color: var(--text);
}
.nav-item.active {
background: #1F1F1F;
color: var(--green);
}
.nav-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.footer {
text-align: center;
padding-top: 16px;
border-top: 1px solid var(--divider);
}
.footer-by {
color: var(--green);
font-weight: 700;
font-size: 12px;
letter-spacing: 0.4px;
}
.footer-version {
color: var(--text-3);
font-size: 11px;
margin-top: 2px;
}
/* ===============================
MAIN
=============================== */
.main {
overflow-y: auto;
padding: 24px 32px 32px;
scroll-behavior: smooth;
}
.view {
display: none;
animation: fadeIn 0.25s ease both;
}
.view.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===============================
HERO
=============================== */
.hero {
position: relative;
border-radius: var(--r-lg);
padding: 28px 32px;
margin-bottom: 24px;
overflow: hidden;
box-shadow: var(--shadow);
}
.hero-green {
background: linear-gradient(135deg, #16893E 0%, #0F3B22 60%, #062614 100%);
}
.hero-blue {
background: linear-gradient(135deg, #2D4D7A 0%, #1A2F4A 60%, #0D1A2B 100%);
}
.hero-purple {
background: linear-gradient(135deg, #5D2C9C 0%, #2D1B4D 60%, #1A0F2E 100%);
}
.hero-content { position: relative; z-index: 2; max-width: 70%; }
.hero-eyebrow {
font-size: 11px;
font-weight: 800;
letter-spacing: 1.5px;
color: var(--green);
margin-bottom: 6px;
}
.hero-eyebrow.blue { color: var(--blue); }
.hero-eyebrow.purple { color: var(--purple); }
.hero-title {
font-size: 36px;
font-weight: 900;
letter-spacing: -0.8px;
line-height: 1.1;
margin-bottom: 8px;
}
.hero-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.hero-deco {
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
font-size: 180px;
font-weight: 900;
opacity: 0.08;
line-height: 1;
user-select: none;
pointer-events: none;
}
/* ===============================
SECTION LABELS
=============================== */
.section-label {
font-size: 11px;
font-weight: 800;
letter-spacing: 1.2px;
color: var(--text-3);
text-transform: uppercase;
margin: 8px 4px 10px;
}
/* ===============================
CARDS
=============================== */
.card {
background: var(--bg-card);
border-radius: var(--r-md);
padding: 18px 20px;
margin-bottom: 18px;
transition: background-color 0.2s ease;
}
.card-accent-purple {
background: linear-gradient(180deg, #1F1532 0%, #181818 80%);
border: 1px solid rgba(168, 85, 247, 0.2);
}
.card.actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.log-card {
padding: 12px;
}
/* ===============================
FIELDS
=============================== */
.field { margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
.field.inline {
display: flex;
align-items: center;
gap: 14px;
}
.field.inline .field-label { margin-bottom: 0; }
.field-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 8px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
}
.row.gap { gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.row.spread { justify-content: space-between; }
.badge {
background: rgba(29, 185, 84, 0.15);
color: var(--green);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
}
/* ===============================
INPUTS
=============================== */
.input {
width: 100%;
background: var(--bg-input);
color: var(--text);
border: 1px solid transparent;
padding: 10px 16px;
border-radius: var(--r-sm);
outline: none;
transition: background-color 0.15s, border-color 0.15s;
}
.input.pill { border-radius: var(--r-pill); }
.input::placeholder { color: var(--text-3); }
.input:focus {
background: #333;
border-color: rgba(29, 185, 84, 0.4);
}
.input-sm { max-width: 200px; }
select.input {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path fill='%23B3B3B3' d='M2 4l4 4 4-4z'/></svg>");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 12px;
padding-right: 36px;
}
input[type="number"] { -moz-appearance: textfield; }
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.path-display {
flex: 1;
background: var(--bg-input);
color: var(--text);
padding: 10px 16px;
border-radius: var(--r-pill);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.path-display:empty,
.path-display.empty { color: var(--text-3); }
/* ===============================
BUTTONS
=============================== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
padding: 10px 22px;
font-weight: 700;
border-radius: var(--r-sm);
white-space: nowrap;
transition: transform 0.06s ease, background-color 0.15s, color 0.15s;
}
.btn.pill { border-radius: var(--r-pill); }
.btn.btn-sm { padding: 8px 16px; font-size: 12px; }
.btn.btn-lg { padding: 14px 32px; font-size: 14px; }
.btn:active { transform: scale(0.96); }
.btn:disabled { cursor: not-allowed; opacity: 0.4; }
.btn:not(:disabled):hover { filter: brightness(1.06); }
.btn-primary {
background: var(--green);
color: #000;
}
.btn-primary:not(:disabled):hover {
background: var(--green-hover);
transform: scale(1.02);
}
.btn-blue {
background: var(--blue);
color: #fff;
}
.btn-purple {
background: var(--purple);
color: #fff;
}
.btn-success {
background: #00CEC9;
color: #000;
margin-top: 8px;
}
.btn-danger {
background: transparent;
color: var(--red);
border: 1px solid var(--red);
}
.btn-danger:not(:disabled):hover {
background: var(--red);
color: #fff;
}
.btn-ghost {
background: var(--bg-input);
color: var(--text);
}
.btn-ghost:not(:disabled):hover {
background: var(--bg-input-hover);
}
.ico {
font-size: 12px;
line-height: 1;
}
/* ===============================
COUNTER / PROGRESS
=============================== */
.counter {
color: var(--text-2);
font-size: 13px;
font-weight: 600;
margin-left: auto;
}
.progress-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.micro-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
color: var(--text-2);
text-transform: uppercase;
}
.percent {
font-size: 14px;
font-weight: 800;
}
.percent.green { color: var(--green); }
.percent.blue { color: var(--blue); }
.progress {
width: 100%;
height: 6px;
background: var(--bg-input);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
width: 0%;
height: 100%;
border-radius: 3px;
transition: width 0.2s ease-out;
}
.progress-fill.green {
background: linear-gradient(90deg, var(--green), var(--green-hover));
}
.progress-fill.blue {
background: linear-gradient(90deg, var(--blue), var(--blue-hover));
}
/* ===============================
LOG BOX
=============================== */
.log {
background: var(--bg-card-2);
border-radius: var(--r-sm);
padding: 14px 16px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
color: var(--text-2);
height: 220px;
overflow-y: auto;
white-space: pre-wrap;
user-select: text;
line-height: 1.6;
}
.log .l-ok { color: var(--green); }
.log .l-err { color: var(--red); }
.log .l-info { color: var(--blue); }
.log .l-warn { color: #FDCB6E; }
.log .l-muted { color: var(--text-3); }
/* ===============================
CHECKBOX
=============================== */
.checkbox {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text);
font-size: 13px;
font-weight: 600;
margin: 6px 0 14px;
user-select: none;
}
.checkbox input { display: none; }
.check-box {
width: 18px;
height: 18px;
border-radius: 4px;
background: var(--bg-input);
border: 1px solid var(--bg-input-hover);
position: relative;
transition: background-color 0.15s, border-color 0.15s;
}
.checkbox input:checked + .check-box {
background: var(--blue);
border-color: var(--blue);
}
.checkbox input:checked + .check-box::after {
content: "";
position: absolute;
left: 5px;
top: 1px;
width: 5px;
height: 11px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* ===============================
INFO ROW (Upgrade tab)
=============================== */
.info-row {
color: var(--green);
font-size: 13px;
font-weight: 700;
margin-bottom: 14px;
min-height: 18px;
}
/* ===============================
SETTINGS
=============================== */
.version-line {
color: var(--text-2);
font-size: 13px;
}
.strong { color: var(--text); font-weight: 700; }
.update-status {
font-size: 13px;
font-weight: 600;
margin-top: 12px;
min-height: 18px;
}
.update-status.ok { color: var(--green); }
.update-status.warn { color: #FDCB6E; }
.update-status.err { color: var(--red); }
.update-notes {
color: var(--text-3);
font-size: 12px;
margin-top: 8px;
white-space: pre-line;
}
.save-row {
display: flex;
align-items: center;
gap: 16px;
margin: 20px 0 32px;
}
.save-status {
color: var(--green);
font-weight: 700;
font-size: 13px;
opacity: 0;
transition: opacity 0.25s;
}
.save-status.show { opacity: 1; }
/* ===============================
MODAL
=============================== */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
animation: fadeIn 0.18s ease both;
}
.modal-backdrop[hidden] { display: none; }
.modal {
background: var(--bg-card);
border-radius: var(--r-lg);
width: min(560px, calc(100% - 40px));
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--divider);
}
.modal-head h2 {
font-size: 17px;
font-weight: 800;
}
.modal-close {
background: transparent;
border: none;
color: var(--text-2);
font-size: 26px;
cursor: pointer;
line-height: 1;
padding: 0 6px;
}
.modal-close:hover { color: var(--text); }
.modal-body {
padding: 18px 22px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 13px;
color: var(--text-2);
white-space: pre-wrap;
overflow-y: auto;
line-height: 1.6;
user-select: text;
flex: 1;
}
.modal-foot {
display: flex;
gap: 10px;
padding: 14px 22px;
border-top: 1px solid var(--divider);
}
/* ===============================
TOAST
=============================== */
.toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translate(-50%, 100px);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text);
padding: 14px 22px;
border-radius: var(--r-pill);
font-weight: 600;
font-size: 13px;
box-shadow: var(--shadow-lg);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.2, 0.7, 0.3, 1), opacity 0.3s;
z-index: 300;
pointer-events: none;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
.toast.toast-error { border-color: var(--red); color: var(--red); }
.toast.toast-success { border-color: var(--green); color: var(--green); }
/* ===============================
SCROLLBARS (WebKit)
=============================== */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 5px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: #606060; }
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
.main::-webkit-scrollbar-thumb { border-color: var(--bg); }
+291
View File
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MusicDownload</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<div class="app">
<!-- ============== SIDEBAR ============== -->
<aside class="sidebar">
<div class="brand">
<div class="brand-logo">
<span>♫</span>
</div>
<div class="brand-name">MusicDownload</div>
</div>
<div class="divider"></div>
<nav class="nav">
<button class="nav-item active" data-view="download">
<span class="nav-icon">⬇</span>
<span>Scarica</span>
</button>
<button class="nav-item" data-view="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
</button>
<button class="nav-item" data-view="settings">
<span class="nav-icon">⚙</span>
<span>Impostazioni</span>
</button>
</nav>
<div class="footer">
<div class="footer-by">by LuZa</div>
<div class="footer-version" id="footerVersion">v1.0</div>
</div>
</aside>
<!-- ============== MAIN ============== -->
<main class="main">
<!-- ===== VIEW: DOWNLOAD ===== -->
<section class="view active" id="view-download">
<header class="hero hero-green">
<div class="hero-content">
<div class="hero-eyebrow">DOWNLOAD</div>
<h1 class="hero-title">Scarica la tua musica</h1>
<p class="hero-subtitle">Spotify · YouTube · SoundCloud · qualsiasi link supportato da yt-dlp</p>
</div>
<div class="hero-deco">♫</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="field">
<label class="field-label">
URL
<span class="badge" id="urlListBadge" hidden></span>
</label>
<div class="row">
<input type="text" id="urlInput" class="input pill" placeholder="Incolla qui un link Spotify, YouTube o SoundCloud…" />
<button class="btn btn-ghost pill" id="loadListBtn">
<span>📂</span> Carica lista
</button>
</div>
</div>
<div class="field">
<label class="field-label">Cartella output</label>
<div class="row">
<div class="path-display" id="dlPathDisplay">Nessuna cartella selezionata</div>
<button class="btn btn-ghost pill btn-sm" id="dlBrowseBtn">Sfoglia</button>
</div>
</div>
</div>
<div class="card actions">
<button class="btn btn-primary pill btn-lg" id="downloadBtn">
<span class="ico">▶</span> Avvia Download
</button>
<button class="btn btn-danger pill btn-lg" id="stopDownloadBtn" disabled>
<span class="ico">◼</span> Stop
</button>
<div class="counter" id="dlCounter"></div>
</div>
<div class="card">
<div class="progress-head">
<span class="micro-label">Progresso</span>
<span class="percent green" id="dlPercent">0%</span>
</div>
<div class="progress">
<div class="progress-fill green" id="dlProgressFill"></div>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="dlLog"></div>
</div>
</section>
<!-- ===== VIEW: UPGRADE ===== -->
<section class="view" id="view-upgrade">
<header class="hero hero-blue">
<div class="hero-content">
<div class="hero-eyebrow blue">UPGRADE QUALITÀ</div>
<h1 class="hero-title">Migliora i tuoi audio</h1>
<p class="hero-subtitle">Riscarica i file a bitrate più alto. Imposta la soglia minima accettabile.</p>
</div>
<div class="hero-deco">⚡</div>
</header>
<h2 class="section-label">Cartella audio</h2>
<div class="card">
<div class="field">
<div class="row">
<div class="path-display" id="upPathDisplay">Nessuna cartella selezionata</div>
<button class="btn btn-ghost pill btn-sm" id="upBrowseBtn">📂 Sfoglia</button>
</div>
</div>
<label class="checkbox">
<input type="checkbox" id="upRecursive" />
<span class="check-box"></span>
<span>Includi sottocartelle</span>
</label>
<div class="info-row" id="upInfo"></div>
<div class="field inline">
<label class="field-label">Soglia HQ (kbps)</label>
<input type="number" id="upThreshold" class="input pill input-sm" value="310" />
</div>
</div>
<div class="card actions">
<button class="btn btn-blue pill btn-lg" id="upgradeBtn">
<span class="ico">▶</span> Avvia Upgrade
</button>
<button class="btn btn-danger pill btn-lg" id="stopUpgradeBtn" disabled>
<span class="ico">◼</span> Stop
</button>
<div class="counter" id="upCounter"></div>
</div>
<div class="card">
<div class="progress-head">
<span class="micro-label">Progresso</span>
<span class="percent blue" id="upPercent">0%</span>
</div>
<div class="progress">
<div class="progress-fill blue" id="upProgressFill"></div>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="upLog"></div>
</div>
</section>
<!-- ===== VIEW: SETTINGS ===== -->
<section class="view" id="view-settings">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">IMPOSTAZIONI</div>
<h1 class="hero-title">Configurazione</h1>
<p class="hero-subtitle">Credenziali Spotify, qualità audio, percorsi e aggiornamenti.</p>
</div>
<div class="hero-deco">⚙</div>
</header>
<h2 class="section-label">Spotify API</h2>
<div class="card card-accent-purple">
<div class="row gap">
<button class="btn btn-purple pill" id="showGuideBtn">Come ottenere le credenziali?</button>
<button class="btn btn-ghost pill" id="openDashboardBtn">Apri Spotify Dashboard</button>
</div>
<div class="field">
<label class="field-label">Client ID</label>
<input type="text" id="clientIdInput" class="input pill" placeholder="Inserisci Client ID" />
</div>
<div class="field">
<label class="field-label">Client Secret</label>
<div class="row">
<input type="password" id="clientSecretInput" class="input pill" placeholder="Inserisci Client Secret" />
<button class="btn btn-ghost pill btn-sm" id="toggleSecretBtn">Mostra</button>
</div>
</div>
</div>
<h2 class="section-label">Download</h2>
<div class="card">
<div class="field">
<label class="field-label">Bitrate</label>
<select id="bitrateSelect" class="input pill input-sm">
<option value="128K">128 kbps</option>
<option value="192K">192 kbps</option>
<option value="256K">256 kbps</option>
<option value="320K" selected>320 kbps</option>
</select>
</div>
<div class="field">
<label class="field-label">Soglia HQ (kbps)</label>
<input type="number" id="hqThresholdInput" class="input pill input-sm" placeholder="310" />
</div>
</div>
<h2 class="section-label">Percorsi</h2>
<div class="card">
<div class="field">
<label class="field-label">Percorso cookies.txt</label>
<div class="row">
<input type="text" id="cookiesInput" class="input pill" placeholder="cookies.txt" />
<button class="btn btn-ghost pill btn-sm" id="browseCookiesBtn">Sfoglia</button>
</div>
</div>
<div class="field">
<label class="field-label">Cartella output predefinita</label>
<div class="row">
<input type="text" id="outputInput" class="input pill" placeholder="MUSICA/" />
<button class="btn btn-ghost pill btn-sm" id="browseOutputBtn">Sfoglia</button>
</div>
</div>
</div>
<h2 class="section-label">Aspetto</h2>
<div class="card">
<div class="field">
<label class="field-label">Tema</label>
<select id="themeSelect" class="input pill input-sm">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
</select>
</div>
</div>
<h2 class="section-label">Aggiornamenti</h2>
<div class="card">
<div class="row spread">
<div class="version-line">
Versione corrente: <span class="strong" id="currentVersionLabel">v1.0</span>
</div>
<button class="btn btn-purple pill" id="checkUpdateBtn">Controlla aggiornamenti</button>
</div>
<div class="update-status" id="updateStatus"></div>
<button class="btn btn-success pill" id="downloadUpdateBtn" hidden>Scarica aggiornamento</button>
<div class="update-notes" id="updateNotes"></div>
</div>
<div class="save-row">
<button class="btn btn-primary pill btn-lg" id="saveBtn">Salva Impostazioni</button>
<div class="save-status" id="saveStatus"></div>
</div>
</section>
</main>
</div>
<!-- ============== MODAL: Spotify Guide ============== -->
<div class="modal-backdrop" id="guideModal" hidden>
<div class="modal">
<div class="modal-head">
<h2>Come ottenere le credenziali Spotify</h2>
<button class="modal-close" id="guideCloseBtn">×</button>
</div>
<pre class="modal-body" id="guideBody"></pre>
<div class="modal-foot">
<button class="btn btn-purple pill" id="guideOpenDashBtn">Apri Spotify Dashboard</button>
<button class="btn btn-ghost pill" id="guideCloseBtn2">Chiudi</button>
</div>
</div>
</div>
<!-- ============== TOAST ============== -->
<div class="toast" id="toast"></div>
<script src="js/app.js"></script>
</body>
</html>
+405
View File
@@ -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");
});