Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1437e8e24 | ||
|
|
acbd9c0b1f | ||
|
|
f226c3fa8e | ||
|
|
85f7ec0993 | ||
|
|
9dbdd74b2f | ||
|
|
8c73651312 | ||
|
|
1fa49fabc4 | ||
|
|
83c47df29c | ||
|
|
9ea14eb81c | ||
|
|
91cfc173d4 | ||
|
|
67e48e54f7 | ||
|
|
0949df031a | ||
|
|
2771fa8075 | ||
|
|
252d76b907 | ||
|
|
58d4896ffe | ||
|
|
91aa1bd6a3 | ||
|
|
48a6e15c7a | ||
|
|
f850e86a88 | ||
|
|
6bff24ff7d | ||
|
|
faf74dfb11 | ||
|
|
d2f3ebe361 | ||
|
|
33c6bc7703 | ||
|
|
be4f7e1024 | ||
|
|
1c52d52986 | ||
|
|
cc5716e87d | ||
|
|
3f31e240be | ||
|
|
13c8e60712 | ||
|
|
f1e57905c1 | ||
|
|
4221a5992b | ||
|
|
70cdfe8983 | ||
|
|
e1aaa83fe9 | ||
|
|
c037da4901 | ||
|
|
21ef3a5699 | ||
|
|
e4bfd76a79 | ||
|
|
e2ac1ca275 | ||
|
|
dc6ae1fbe1 | ||
|
|
0e14d161d0 | ||
|
|
7dd793253b | ||
|
|
5771d631fd | ||
|
|
b74a0a53ec | ||
|
|
415175ec4d | ||
|
|
d8349fb79f | ||
|
|
1414c5199c | ||
|
|
6e96e8c6b7 |
No files matched your search
+383
-3
@@ -8,9 +8,14 @@ import re
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
|
||||
from core import beatport, spotify_client
|
||||
from core import license as license_mod
|
||||
from core.downloader import (
|
||||
download_playlist,
|
||||
@@ -42,7 +47,15 @@ from core.upgrader import (
|
||||
|
||||
|
||||
SPOTIFY_GUIDE_TEXT = """\
|
||||
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
|
||||
NOTE: Spotify oggi consente di creare le chiavi API SOLO con un account
|
||||
Spotify Premium attivo. Senza Premium puoi comunque scaricare:
|
||||
- YouTube, SoundCloud, TikTok, Instagram, Facebook (senza setup)
|
||||
- Brani Spotify SINGOLI (incolla il link e basta, l'app fa il resto)
|
||||
Le chiavi Spotify servono solo per playlist e album da Spotify.
|
||||
|
||||
Procedura (se hai Spotify Premium):
|
||||
|
||||
1) Vai su https://developer.spotify.com/dashboard e accedi.
|
||||
2) Clicca "Create app".
|
||||
3) Compila:
|
||||
- App name: MusicTools (o quello che vuoi)
|
||||
@@ -55,7 +68,7 @@ SPOTIFY_GUIDE_TEXT = """\
|
||||
- Client Secret (clicca "View client secret")
|
||||
5) Incollali qui sotto e premi "Salva".
|
||||
|
||||
Note: credenziali gratuite, no Premium. Limite 100 richieste/minuto.
|
||||
Limite tecnico Spotify: 100 richieste/minuto.
|
||||
"""
|
||||
|
||||
|
||||
@@ -221,7 +234,12 @@ class Api:
|
||||
),
|
||||
"plan": plan,
|
||||
}
|
||||
# 2) Quota gate server-side
|
||||
# 2) Quota gate server-side: skippato per i piani senza limite
|
||||
# giornaliero (Annual) — evita una chiamata HTTP inutile e fa
|
||||
# funzionare l'app anche offline per quei piani.
|
||||
plan = license_mod.get_plan()
|
||||
if plan.get("daily_limit") is None:
|
||||
return None
|
||||
try:
|
||||
res = license_mod.consume_quota(feature)
|
||||
except license_mod.LicenseNetworkError as e:
|
||||
@@ -528,6 +546,40 @@ class Api:
|
||||
self._download_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def start_urls_download(self, payload: dict) -> dict:
|
||||
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
||||
(bypass search). Usato dal flow del tab 'YouTube Search'."""
|
||||
if self._any_job_running():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
urls = payload.get("urls") or []
|
||||
titles = payload.get("titles") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
subfolder = (payload.get("subfolder") or "").strip()
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessuna URL fornita"}
|
||||
if len(urls) != len(titles):
|
||||
return {"ok": False, "error": "urls e titles devono avere stessa lunghezza"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
if subfolder:
|
||||
safe = subfolder.replace("/", "_").replace("\\", "_").strip()
|
||||
if safe:
|
||||
output_dir = os.path.join(output_dir, safe)
|
||||
|
||||
gate = self._gate("audio")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._urls_worker,
|
||||
args=(list(urls), list(titles), 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...")
|
||||
@@ -585,6 +637,53 @@ class Api:
|
||||
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb)
|
||||
self._emit("download:done", {"ok": True})
|
||||
|
||||
def _urls_worker(self, urls: list, titles: list, output_dir: str) -> None:
|
||||
from core.downloader import download_urls
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
view = "download"
|
||||
|
||||
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.10
|
||||
|
||||
def progress_cb(idx, total, title, status, pct):
|
||||
if status == "downloading":
|
||||
now = time.monotonic()
|
||||
if now - _last[0] < _THROTTLE:
|
||||
return
|
||||
_last[0] = now
|
||||
|
||||
payload_evt = {
|
||||
"idx": idx, "total": total, "track": title,
|
||||
"status": status, "pct": pct,
|
||||
"url_idx": 0, "url_total": 1,
|
||||
}
|
||||
if status == "skipped":
|
||||
self._log(view, f"[SKIP] {title} (gia scaricato)")
|
||||
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||
elif status == "downloading":
|
||||
payload_evt["overall"] = min((idx / total) + (pct / 100 / total), 1.0) if total else 0
|
||||
elif status == "done":
|
||||
self._log(view, f"[OK] {title}")
|
||||
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Download interrotto.")
|
||||
elif status == "completed":
|
||||
payload_evt["overall"] = 1.0
|
||||
self._log(view, "[INFO] Download completato!")
|
||||
elif status.startswith("error"):
|
||||
self._log(view, f"[ERRORE] {title}: {status}")
|
||||
|
||||
self._emit("download:progress", payload_evt)
|
||||
|
||||
download_urls(urls, titles, output_dir, bitrate, cookies_path, progress_cb)
|
||||
self._emit("download:done", {"ok": True})
|
||||
|
||||
def _download_worker(self, urls: list, output_dir: str) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
@@ -890,3 +989,284 @@ class Api:
|
||||
})
|
||||
|
||||
self._emit("video:done", {"ok": True})
|
||||
|
||||
# ================================================================
|
||||
# Beatport charts
|
||||
# ================================================================
|
||||
def beatport_genres(self) -> list:
|
||||
"""Lista dei generi disponibili per il dropdown UI."""
|
||||
return beatport.list_genres()
|
||||
|
||||
def beatport_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
|
||||
"""Fetches la Top 100 per il genere. Salva anche l'ultimo genere in config.
|
||||
Ritorna {ok: True, tracks: [...]} oppure {ok: False, error, message}."""
|
||||
# Memoria: ricorda l'ultimo genere usato
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["beatport_last_genre"] = slug
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
tracks = beatport.fetch_top100(slug, force_refresh=force_refresh)
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "invalid_genre", "message": str(e)}
|
||||
except beatport.BeatportUnreachableError as e:
|
||||
return {"ok": False, "error": "unreachable", "message": str(e)}
|
||||
except beatport.BeatportParseError as e:
|
||||
return {"ok": False, "error": "parse", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
|
||||
|
||||
def _beatport_output_dir(self, out_root: str, genre_name: str) -> Path:
|
||||
"""Calcola la cartella dove finiscono i file Beatport.
|
||||
Coerente col comportamento di `start_tracks_download`, che sanitizza
|
||||
gli slash nel subfolder (`/` -> `_`), quindi `Beatport/<genre>`
|
||||
diventa flat: `Beatport_<genre>`."""
|
||||
safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip()
|
||||
subfolder = f"Beatport_{safe_genre}" if safe_genre else "Beatport"
|
||||
return Path(out_root) / subfolder
|
||||
|
||||
def beatport_check_existing(self, tracks: list, genre_name: str) -> list:
|
||||
"""Per ogni track ritorna True se il file esiste gia' nella cartella
|
||||
di output Beatport (stessa dove `beatport_download_selected` scrive).
|
||||
Match euristico: nel filename (senza extension) devono comparire sia
|
||||
il titolo che il primo artista (case-insensitive)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
|
||||
out_dir = self._beatport_output_dir(out_root, genre_name)
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").lower().strip()
|
||||
artists = (t.get("artists") or "")
|
||||
first_artist = artists.split(",")[0].split("&")[0].strip().lower()
|
||||
if not title or not first_artist:
|
||||
result.append(False)
|
||||
continue
|
||||
found = any(
|
||||
(title in stem and first_artist in stem)
|
||||
for stem in existing_stems
|
||||
)
|
||||
result.append(found)
|
||||
return result
|
||||
|
||||
def beatport_download_selected(self, tracks: list, genre_name: str) -> dict:
|
||||
"""Converte i BeatportTrack in tracklist compatibile con start_tracks_download
|
||||
e lancia il download riusando l'infrastruttura esistente (license gate,
|
||||
skip file gia' scaricati, log su canale 'download', ecc.)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
# Sfrutta lo stesso computed path del check_existing per garantire
|
||||
# che i due metodi restino allineati (senza affidarsi a start_tracks_download
|
||||
# per la sanitizzazione).
|
||||
target_dir = self._beatport_output_dir(out_root, genre_name)
|
||||
subfolder = target_dir.name # es. "Beatport_melodic-house-techno"
|
||||
|
||||
converted = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").strip()
|
||||
artists = (t.get("artists") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
converted.append({"name": title, "artist": artists})
|
||||
|
||||
if not converted:
|
||||
return {"ok": False, "error": "Nessun brano valido"}
|
||||
|
||||
return self.start_tracks_download({
|
||||
"tracks": converted,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
})
|
||||
|
||||
# ================================================================
|
||||
# Music Search — Spotify + YouTube
|
||||
# ================================================================
|
||||
|
||||
def _music_output_dir(self, out_root: str, source: str) -> Path:
|
||||
"""Cartella target per Spotify/YouTube search. Coerente col pattern
|
||||
di start_tracks_download (subfolder singolo, no nested)."""
|
||||
safe = source.replace("/", "_").replace("\\", "_").strip()
|
||||
return Path(out_root) / safe
|
||||
|
||||
# ---- Spotify ----
|
||||
|
||||
def spotify_search(self, query: str, artist_mode: bool = False) -> dict:
|
||||
"""Cerca su Spotify. Free-form (limit 50) o artist-mode (discografia)."""
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return {"ok": False, "error": "empty_query", "message": "Query vuota"}
|
||||
|
||||
# Salva stato
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["spotify_search_last_query"] = query
|
||||
cfg["spotify_search_artist_mode"] = bool(artist_mode)
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cfg = load_config()
|
||||
cid = (cfg.get("client_id") or "").strip()
|
||||
secret = (cfg.get("client_secret") or "").strip()
|
||||
if not cid or not secret:
|
||||
return {"ok": False, "error": "no_creds", "message": "Credenziali Spotify mancanti"}
|
||||
|
||||
try:
|
||||
token = spotify_client.get_access_token(cid, secret)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "auth", "message": str(e)}
|
||||
|
||||
try:
|
||||
if artist_mode:
|
||||
tracks = spotify_client.search_artist_discography(token, query)
|
||||
else:
|
||||
tracks = spotify_client.search_tracks(token, query, limit=50)
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "artist_not_found", "message": str(e)}
|
||||
except requests.HTTPError as e:
|
||||
code = e.response.status_code if e.response is not None else 0
|
||||
if code == 429:
|
||||
return {"ok": False, "error": "rate_limit", "message": "Spotify limitante - attendi qualche secondo"}
|
||||
return {"ok": False, "error": "server", "message": f"Spotify HTTP {code}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "unknown", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": tracks}
|
||||
|
||||
def spotify_check_existing(self, tracks: list) -> list:
|
||||
"""True per ogni track già presente in output_dir/Spotify/."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
out_dir = self._music_output_dir(out_root, "Spotify")
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("name") or "").lower().strip()
|
||||
artists = (t.get("artists") or "")
|
||||
first_artist = artists.split(",")[0].strip().lower()
|
||||
if not title or not first_artist:
|
||||
result.append(False)
|
||||
continue
|
||||
result.append(any((title in stem and first_artist in stem) for stem in existing_stems))
|
||||
return result
|
||||
|
||||
def spotify_search_download(self, tracks: list) -> dict:
|
||||
"""Scarica i track Spotify selezionati (name+artist → YouTube search)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
target = self._music_output_dir(out_root, "Spotify")
|
||||
subfolder = target.name
|
||||
|
||||
converted = []
|
||||
for t in tracks:
|
||||
title = (t.get("name") or "").strip()
|
||||
artists = (t.get("artists") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
converted.append({"name": title, "artist": artists})
|
||||
|
||||
if not converted:
|
||||
return {"ok": False, "error": "Nessun brano valido"}
|
||||
|
||||
return self.start_tracks_download({
|
||||
"tracks": converted,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
})
|
||||
|
||||
# ---- YouTube ----
|
||||
|
||||
def youtube_search(self, query: str) -> dict:
|
||||
"""Cerca 50 risultati su YouTube via yt-dlp."""
|
||||
from core import youtube_search as yts
|
||||
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return {"ok": False, "error": "empty_query", "message": "Query vuota"}
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["youtube_search_last_query"] = query
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
results = yts.search_youtube(query, limit=50)
|
||||
except RuntimeError as e:
|
||||
return {"ok": False, "error": "ytdlp", "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "unknown", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": results}
|
||||
|
||||
def youtube_check_existing(self, tracks: list) -> list:
|
||||
"""True per ogni track già presente in output_dir/YouTube/. Match sul titolo video."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
out_dir = self._music_output_dir(out_root, "YouTube")
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").lower().strip()
|
||||
if not title:
|
||||
result.append(False)
|
||||
continue
|
||||
result.append(any(
|
||||
(title in stem or stem in title)
|
||||
for stem in existing_stems
|
||||
))
|
||||
return result
|
||||
|
||||
def youtube_search_download(self, tracks: list) -> dict:
|
||||
"""Scarica direttamente gli URL YouTube selezionati (no re-search)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
target = self._music_output_dir(out_root, "YouTube")
|
||||
subfolder = target.name
|
||||
|
||||
urls: list = []
|
||||
titles: list = []
|
||||
for t in tracks:
|
||||
url = (t.get("url") or "").strip()
|
||||
title = (t.get("title") or "").strip()
|
||||
if not url or not title:
|
||||
continue
|
||||
urls.append(url)
|
||||
titles.append(title)
|
||||
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessun URL valido"}
|
||||
|
||||
return self.start_urls_download({
|
||||
"urls": urls,
|
||||
"titles": titles,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
})
|
||||
+238
@@ -238,6 +238,14 @@ def run_pyinstaller():
|
||||
app_path = DIST_DIR / "MusicTools.app"
|
||||
if app_path.exists():
|
||||
log(f"Build completata: {app_path}")
|
||||
patch_info_plist(app_path)
|
||||
# NOTA: dal v1.7.14 il microfono viene letto direttamente da
|
||||
# PyObjC/AVCaptureSession nel processo Python (che ha il TCC del
|
||||
# bundle), non da ffmpeg subprocess. ffmpeg fa solo la conversione
|
||||
# CAF -> MP3 (no microfono = no TCC). Per questo non serve piu'
|
||||
# incapsularlo in un sub-bundle .app con NSMicrophoneUsageDescription.
|
||||
# wrap_subprocess_in_bundle(app_path)
|
||||
adhoc_codesign(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")
|
||||
@@ -249,6 +257,236 @@ def run_pyinstaller():
|
||||
log(f"Trovata directory: {alt}")
|
||||
|
||||
|
||||
def patch_info_plist(app_path):
|
||||
"""Aggiunge le chiavi TCC nell'Info.plist del bundle .app.
|
||||
|
||||
NSMicrophoneUsageDescription e' obbligatoria: senza, macOS uccide
|
||||
l'app con SIGABRT quando AVFoundation tenta di aprire il microfono.
|
||||
|
||||
Usiamo plistlib invece di PlistBuddy perche' i valori con apostrofi
|
||||
(es. "l'audio") rompono il parser shell-based di PlistBuddy.
|
||||
"""
|
||||
import plistlib
|
||||
|
||||
info_plist = app_path / "Contents" / "Info.plist"
|
||||
if not info_plist.exists():
|
||||
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
|
||||
return
|
||||
|
||||
log("Patching Info.plist con le chiavi TCC...")
|
||||
with open(info_plist, "rb") as f:
|
||||
data = plistlib.load(f)
|
||||
|
||||
data["NSMicrophoneUsageDescription"] = (
|
||||
"MusicTools usa il microfono per registrare l'audio del sistema "
|
||||
"(ad esempio tramite BlackHole o un altro dispositivo loopback)."
|
||||
)
|
||||
# Bundle identifier stabile aiuta TCC a riconoscere l'app tra le sessioni
|
||||
data["CFBundleIdentifier"] = "com.djluza.musictools"
|
||||
data["LSApplicationCategoryType"] = "public.app-category.music"
|
||||
|
||||
with open(info_plist, "wb") as f:
|
||||
plistlib.dump(data, f)
|
||||
|
||||
log(f"Info.plist aggiornato. Chiavi: {sorted(data.keys())[-5:]}")
|
||||
# Verifica
|
||||
with open(info_plist, "rb") as f:
|
||||
verify = plistlib.load(f)
|
||||
if "NSMicrophoneUsageDescription" not in verify:
|
||||
log("ERRORE CRITICO: NSMicrophoneUsageDescription NON e' stata scritta!")
|
||||
else:
|
||||
log("Verifica OK: NSMicrophoneUsageDescription presente.")
|
||||
|
||||
|
||||
_BUNDLE_ID = "com.djluza.musictools"
|
||||
|
||||
|
||||
def _subprocess_info_plist(executable, bundle_id):
|
||||
"""Info.plist per un mini-bundle che incapsula un binario subprocess.
|
||||
|
||||
NSMicrophoneUsageDescription qui dentro e' la chiave: senza di essa,
|
||||
macOS uccide il subprocess con SIGABRT appena tenta di aprire un
|
||||
device audio, anche se il main MusicTools ha il proprio TCC concesso.
|
||||
"""
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
||||
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
||||
'<plist version="1.0">\n'
|
||||
'<dict>\n'
|
||||
f' <key>CFBundleExecutable</key><string>{executable}</string>\n'
|
||||
f' <key>CFBundleIdentifier</key><string>{bundle_id}</string>\n'
|
||||
f' <key>CFBundleName</key><string>{executable}</string>\n'
|
||||
' <key>CFBundlePackageType</key><string>APPL</string>\n'
|
||||
' <key>CFBundleShortVersionString</key><string>1.0</string>\n'
|
||||
' <key>CFBundleVersion</key><string>1</string>\n'
|
||||
' <key>LSBackgroundOnly</key><true/>\n'
|
||||
' <key>LSUIElement</key><true/>\n'
|
||||
' <key>NSMicrophoneUsageDescription</key>\n'
|
||||
' <string>MusicTools usa il microfono per registrare l\'audio del '
|
||||
'sistema (BlackHole o altri dispositivi loopback).</string>\n'
|
||||
'</dict>\n'
|
||||
'</plist>\n'
|
||||
)
|
||||
|
||||
|
||||
def wrap_subprocess_in_bundle(app_path):
|
||||
"""Sposta ffmpeg e ffprobe in mini-bundle .app dentro Frameworks/.
|
||||
|
||||
macOS 14+ ENFORCE il fatto che ogni binario che apre device
|
||||
privacy-sensitive (microfono, camera, ...) debba avere un proprio
|
||||
Info.plist accessibile con NSMicrophoneUsageDescription. Quando
|
||||
ffmpeg viene lanciato come binario standalone (anche se figlio di
|
||||
MusicTools), non c'e' Info.plist associato -> SIGABRT.
|
||||
|
||||
Soluzione: incapsulare ffmpeg in MusicTools.app/Contents/Frameworks/
|
||||
ffmpeg.app/Contents/MacOS/ffmpeg, con Info.plist nel suo bundle.
|
||||
Stesso per ffprobe.
|
||||
"""
|
||||
log("Wrapping ffmpeg e ffprobe in sub-bundle .app...")
|
||||
fw = app_path / "Contents" / "Frameworks"
|
||||
if not fw.exists():
|
||||
log("ATTENZIONE: Contents/Frameworks non trovato, skip wrapping.")
|
||||
return
|
||||
|
||||
for name in ("ffmpeg", "ffprobe"):
|
||||
src = fw / name
|
||||
if not src.exists() or not src.is_file():
|
||||
log(f" skip {name}: non trovato in Frameworks/")
|
||||
continue
|
||||
wrap = fw / f"{name}.app"
|
||||
if wrap.exists():
|
||||
shutil.rmtree(wrap)
|
||||
macos = wrap / "Contents" / "MacOS"
|
||||
macos.mkdir(parents=True, exist_ok=True)
|
||||
dest = macos / name
|
||||
shutil.move(str(src), str(dest))
|
||||
dest.chmod(0o755)
|
||||
|
||||
# Aggiungi un nuovo rpath che punta a Contents/Frameworks/ (dove
|
||||
# stanno le dylib del bundle MusicTools).
|
||||
# @executable_path = ffmpeg.app/Contents/MacOS/
|
||||
# ../../../ = MusicTools.app/Contents/Frameworks/
|
||||
subprocess.run(
|
||||
["install_name_tool", "-add_rpath",
|
||||
"@executable_path/../../../", str(dest)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Crea Info.plist con NSMicrophoneUsageDescription.
|
||||
info = wrap / "Contents" / "Info.plist"
|
||||
bundle_id = f"com.djluza.musictools.{name}"
|
||||
info.write_text(_subprocess_info_plist(name, bundle_id))
|
||||
log(f" wrap: {name} -> {dest.relative_to(app_path)}")
|
||||
|
||||
|
||||
def adhoc_codesign(app_path):
|
||||
"""Ad-hoc codesign dell'intero bundle .app con identifier coerente.
|
||||
|
||||
macOS tratta ogni binario Mach-O firmato come una "app" distinta per
|
||||
le decisioni TCC (microfono, camera, ecc.). Con `codesign --deep`
|
||||
standard, i nested binary (ffmpeg, ffprobe, yt-dlp) ricevono ognuno
|
||||
un identifier auto-generato del tipo `<nome>-<hash>`, diverso da
|
||||
quello del bundle principale -> TCC NEGA al subprocess.
|
||||
|
||||
Soluzione: firmiamo PRIMA ogni binario interno con `--identifier
|
||||
com.djluza.musictools`, poi il bundle .app intero. Cosi' TCC vede
|
||||
tutti i Mach-O come parte dello stesso "team" e propaga il permesso
|
||||
del main ai subprocess.
|
||||
"""
|
||||
log("Ad-hoc codesign del bundle (identifier coerente per TCC)...")
|
||||
try:
|
||||
# 1) Pulisci attributi estesi (rimuove quarantine residui dal build)
|
||||
subprocess.run(["xattr", "-cr", str(app_path)], capture_output=True)
|
||||
|
||||
# 2) Trova tutti i Mach-O nested (binari ed eventuali .dylib).
|
||||
# Li firmiamo uno a uno con identifier coerente, dal piu' profondo
|
||||
# al piu' esterno (richiesta da codesign).
|
||||
nested_machos = []
|
||||
for p in sorted(app_path.rglob("*"), key=lambda x: -len(x.parts)):
|
||||
if not p.is_file():
|
||||
continue
|
||||
# Skip risorse non eseguibili
|
||||
if p.suffix in (".plist", ".nib", ".png", ".icns", ".svg",
|
||||
".html", ".css", ".js", ".json", ".md", ".txt"):
|
||||
continue
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
magic = f.read(4)
|
||||
except Exception:
|
||||
continue
|
||||
# Mach-O magic numbers (32/64-bit, big/little endian, fat)
|
||||
if magic in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe",
|
||||
b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
|
||||
b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"):
|
||||
nested_machos.append(p)
|
||||
|
||||
log(f"Trovati {len(nested_machos)} binari Mach-O da firmare.")
|
||||
|
||||
for p in nested_machos:
|
||||
# Per ffmpeg/ffprobe dentro al sub-bundle .app, usa l'identifier
|
||||
# del SUB-bundle (TCC li tratta come app separate con Info.plist
|
||||
# proprio). Per gli altri binari nested (dylib, framework), usa
|
||||
# l'identifier del bundle principale.
|
||||
ident = _BUNDLE_ID
|
||||
parts = p.relative_to(app_path).parts
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
if sub in parts:
|
||||
ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||
break
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", ident,
|
||||
"--timestamp=none",
|
||||
str(p)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f" warn: firma {p.name} fallita: {r.stderr.strip()}")
|
||||
|
||||
# Firma anche i sub-bundle .app come bundle (con il loro Info.plist).
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
sub_path = app_path / "Contents" / "Frameworks" / sub
|
||||
if not sub_path.exists():
|
||||
continue
|
||||
sub_ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", sub_ident,
|
||||
"--timestamp=none",
|
||||
str(sub_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f" warn: firma {sub} fallita: {r.stderr.strip()}")
|
||||
|
||||
# 3) Firma del bundle .app principale (l'identifier corretto viene
|
||||
# letto dall'Info.plist - CFBundleIdentifier che gia' settiamo).
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", _BUNDLE_ID,
|
||||
"--timestamp=none",
|
||||
str(app_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f"ATTENZIONE: firma bundle fallita: {r.stderr.strip()}")
|
||||
else:
|
||||
log("Codesign ad-hoc completato.")
|
||||
|
||||
# 4) Verifica finale
|
||||
verify = subprocess.run(
|
||||
["codesign", "--verify", "--deep", "--strict", str(app_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if verify.returncode == 0:
|
||||
log("Verifica firma OK.")
|
||||
else:
|
||||
log(f"NOTA verifica firma: {verify.stderr.strip()}")
|
||||
except FileNotFoundError:
|
||||
log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main
|
||||
# =========================================================================
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Fetch Top 100 Beatport per genere.
|
||||
|
||||
Approccio: estrai il JSON `__NEXT_DATA__` dal HTML della pagina Next.js.
|
||||
Bypass Cloudflare via curl_cffi (TLS impersonation).
|
||||
Vedi docs/superpowers/specs/2026-07-14-beatport-charts-design.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from curl_cffi import requests as _cffi_requests
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BeatportTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str # es. "Extended Mix", "Original Mix", ""
|
||||
artists: str # es. "A, B & C" già formattato
|
||||
duration_sec: int
|
||||
beatport_id: int
|
||||
image_url: str = "" # URL cover art (default vuoto per retrocompatibilità test)
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
"""Formato testo compatibile coi file .txt curati a mano:
|
||||
'Artista – Titolo (Mix) (M:SS)'."""
|
||||
m, s = divmod(self.duration_sec, 60)
|
||||
return f"{self.artists} – {self.title} ({self.mix}) ({m}:{s:02d})"
|
||||
|
||||
@property
|
||||
def spotify_query(self) -> str:
|
||||
"""Query pura per la search Spotify (senza mix name, che disturba
|
||||
il matching su titoli tipo 'Extended Mix')."""
|
||||
return f"{self.artists} {self.title}"
|
||||
|
||||
|
||||
class BeatportError(Exception):
|
||||
"""Base per errori Beatport."""
|
||||
|
||||
|
||||
class BeatportUnreachableError(BeatportError):
|
||||
"""Rete o server Beatport non raggiungibile / 5xx."""
|
||||
|
||||
|
||||
class BeatportParseError(BeatportError):
|
||||
"""HTML/JSON ricevuto ma non conforme allo schema atteso."""
|
||||
|
||||
|
||||
_NEXT_DATA_RE = re.compile(
|
||||
r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _extract_next_data(html: str) -> dict:
|
||||
"""Estrae il payload JSON dallo script <__NEXT_DATA__> di Next.js."""
|
||||
m = _NEXT_DATA_RE.search(html)
|
||||
if not m:
|
||||
raise BeatportParseError("__NEXT_DATA__ non trovato nella pagina Beatport")
|
||||
try:
|
||||
return json.loads(m.group(1))
|
||||
except json.JSONDecodeError as e:
|
||||
raise BeatportParseError(f"__NEXT_DATA__ JSON malformato: {e}") from e
|
||||
|
||||
|
||||
# Mappa slug URL Beatport -> (numeric_id, display_name)
|
||||
# Enumerata via scripts/refresh_beatport_genres.py (Task 1).
|
||||
# Copia dell'output di /tmp/genres_output.txt (31 generi).
|
||||
GENRES: dict = {
|
||||
"drum-bass": (1, "Drum & Bass"),
|
||||
"hard-techno": (2, "Hard Techno"),
|
||||
"electronica": (3, "Electronica"),
|
||||
"house": (5, "House"),
|
||||
"techno-peak-time-driving": (6, "Techno (Peak Time / Driving)"),
|
||||
"trance-main-floor": (7, "Trance (Main Floor)"),
|
||||
"hard-dance-hardcore-neo-rave": (8, "Hard Dance / Hardcore / Neo Rave"),
|
||||
"breaks-breakbeat-uk-bass": (9, "Breaks / Breakbeat / UK Bass"),
|
||||
"tech-house": (11, "Tech House"),
|
||||
"deep-house": (12, "Deep House"),
|
||||
"psy-trance": (13, "Psy-Trance"),
|
||||
"minimal-deep-tech": (14, "Minimal / Deep Tech"),
|
||||
"progressive-house": (15, "Progressive House"),
|
||||
"dubstep": (18, "Dubstep"),
|
||||
"indie-dance": (37, "Indie Dance"),
|
||||
"trap-future-bass": (38, "Trap / Future Bass"),
|
||||
"dance-pop": (39, "Dance / Pop"),
|
||||
"nu-disco-disco": (50, "Nu Disco / Disco"),
|
||||
"funky-house": (81, "Funky House"),
|
||||
"bass-club": (85, "Bass / Club"),
|
||||
"uk-garage-bassline": (86, "UK Garage / Bassline"),
|
||||
"afro-house": (89, "Afro House"),
|
||||
"melodic-house-techno": (90, "Melodic House & Techno"),
|
||||
"bass-house": (91, "Bass House"),
|
||||
"techno-raw-deep-hypnotic": (92, "Techno (Raw / Deep / Hypnotic)"),
|
||||
"organic-house": (93, "Organic House"),
|
||||
"electro-classic-detroit-modern": (94, "Electro (Classic / Detroit / Modern)"),
|
||||
"140-deep-dubstep-grime": (95, "140 / Deep Dubstep / Grime"),
|
||||
"mainstage": (96, "Mainstage"),
|
||||
"jackin-house": (97, "Jackin House"),
|
||||
"trance-raw-deep-hypnotic": (99, "Trance (Raw / Deep / Hypnotic)"),
|
||||
}
|
||||
|
||||
|
||||
def list_genres() -> list:
|
||||
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||
result = [
|
||||
{"slug": slug, "id": gid, "name": name}
|
||||
for slug, (gid, name) in GENRES.items()
|
||||
]
|
||||
result.sort(key=lambda g: g["name"].casefold())
|
||||
return result
|
||||
|
||||
|
||||
def _find_tracks_results(data: dict) -> list:
|
||||
"""Cerca dentro le queries dehydrated il primo `results` che ha almeno 50 elementi
|
||||
e la shape di una track (chiave `id` presente)."""
|
||||
try:
|
||||
queries = data["props"]["pageProps"]["dehydratedState"]["queries"]
|
||||
except (KeyError, TypeError) as e:
|
||||
raise BeatportParseError(
|
||||
f"schema JSON inatteso, `results` non localizzabile (chiave mancante: {e})"
|
||||
) from e
|
||||
|
||||
for q in queries:
|
||||
state_data = (q or {}).get("state", {}).get("data")
|
||||
if not isinstance(state_data, dict):
|
||||
continue
|
||||
results = state_data.get("results")
|
||||
if isinstance(results, list) and len(results) >= 50:
|
||||
if results and isinstance(results[0], dict) and "id" in results[0]:
|
||||
return results
|
||||
raise BeatportParseError("nessun `results` di 50+ track trovato in __NEXT_DATA__")
|
||||
|
||||
|
||||
def _format_artists(artists_field: object) -> str:
|
||||
"""Beatport ritorna artists come lista di dict {name, ...}.
|
||||
Formatta come 'A, B & C' (& prima dell'ultimo)."""
|
||||
if not isinstance(artists_field, list) or not artists_field:
|
||||
return ""
|
||||
names = [a.get("name", "") for a in artists_field if isinstance(a, dict)]
|
||||
names = [n for n in names if n]
|
||||
if not names:
|
||||
return ""
|
||||
if len(names) == 1:
|
||||
return names[0]
|
||||
return ", ".join(names[:-1]) + " & " + names[-1]
|
||||
|
||||
|
||||
def _extract_image_url(image_field: object, size: int = 95) -> str:
|
||||
"""Estrae URL cover art dall'oggetto `image` di Beatport.
|
||||
Preferisce `dynamic_uri` sostituendo {w}x{h}, fallback a `uri` fisso."""
|
||||
if not isinstance(image_field, dict):
|
||||
return ""
|
||||
dyn = image_field.get("dynamic_uri") or ""
|
||||
if isinstance(dyn, str) and "{w}" in dyn and "{h}" in dyn:
|
||||
return dyn.replace("{w}", str(size)).replace("{h}", str(size))
|
||||
return image_field.get("uri") or ""
|
||||
|
||||
|
||||
def _parse_tracks(data: dict) -> list:
|
||||
"""Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione."""
|
||||
raw = _find_tracks_results(data)
|
||||
out: list = []
|
||||
for i, item in enumerate(raw, 1):
|
||||
try:
|
||||
length_ms = int(item.get("length_ms") or 0)
|
||||
track = BeatportTrack(
|
||||
position=i,
|
||||
title=str(item.get("name") or "").strip(),
|
||||
mix=str(item.get("mix_name") or "").strip(),
|
||||
artists=_format_artists(item.get("artists")),
|
||||
duration_sec=length_ms // 1000,
|
||||
beatport_id=int(item.get("id") or 0),
|
||||
image_url=_extract_image_url(item.get("image")),
|
||||
)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
|
||||
out.append(track)
|
||||
return out
|
||||
|
||||
|
||||
_IMPERSONATE = "chrome131" # aggiorna se CF rompe il fingerprint
|
||||
_REQUEST_TIMEOUT = 15
|
||||
_MAX_ATTEMPTS = 3
|
||||
_BACKOFF_SEC = [1, 3] # attese fra tentativi
|
||||
_CACHE_TTL_SEC = 15 * 60
|
||||
|
||||
# Cache in-memory: slug → (timestamp_epoch, list[BeatportTrack])
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def _url_for(slug: str) -> str:
|
||||
gid, _ = GENRES[slug]
|
||||
return f"https://www.beatport.com/genre/{slug}/{gid}/top-100"
|
||||
|
||||
|
||||
def _do_get(url: str) -> str:
|
||||
"""GET con retry e backoff. Solleva BeatportUnreachableError su fallimento definitivo."""
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
resp = _cffi_requests.get(
|
||||
url,
|
||||
impersonate=_IMPERSONATE,
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
)
|
||||
if resp.status_code >= 500 or resp.status_code == 403:
|
||||
raise Exception(f"HTTP {resp.status_code}")
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt < _MAX_ATTEMPTS - 1:
|
||||
time.sleep(_BACKOFF_SEC[attempt])
|
||||
raise BeatportUnreachableError(f"Beatport irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}")
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list:
|
||||
"""Fetches la Top 100 Beatport per il genere dato.
|
||||
|
||||
Cache in-memory 15 min. `force_refresh=True` bypassa la cache.
|
||||
|
||||
Raises:
|
||||
ValueError: se slug non è in GENRES.
|
||||
BeatportUnreachableError: rete/5xx dopo i retry.
|
||||
BeatportParseError: HTML/JSON non conforme allo schema.
|
||||
"""
|
||||
if slug not in GENRES:
|
||||
raise ValueError(f"slug genere non valido: {slug!r}")
|
||||
|
||||
now = time.time()
|
||||
if not force_refresh:
|
||||
cached = _cache.get(slug)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL_SEC:
|
||||
return cached[1]
|
||||
|
||||
html = _do_get(_url_for(slug))
|
||||
data = _extract_next_data(html)
|
||||
tracks = _parse_tracks(data)
|
||||
_cache[slug] = (now, tracks)
|
||||
return tracks
|
||||
+7
-1
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.7.7"
|
||||
VERSION = "v1.8.3"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
@@ -70,6 +70,12 @@ DEFAULTS = {
|
||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||
"output_dir": str(_project_dir / "MUSICA"),
|
||||
"theme": "dark",
|
||||
# ---- Beatport ----
|
||||
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
|
||||
# ---- Music Search (Spotify + YouTube) ----
|
||||
"spotify_search_last_query": "",
|
||||
"spotify_search_artist_mode": False,
|
||||
"youtube_search_last_query": "",
|
||||
# ---- Licenza ----
|
||||
"license_key": "", # chiave fornita all'utente via email
|
||||
"license_email": "", # email associata all'acquisto
|
||||
|
||||
@@ -399,6 +399,125 @@ def download_direct_url(
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def download_urls(
|
||||
urls: list,
|
||||
titles: list,
|
||||
output_dir: str,
|
||||
bitrate: str = "320K",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica direttamente da URL YouTube (bypassa search).
|
||||
|
||||
Usa gli stessi flag yt-dlp di download_playlist. La differenza e che
|
||||
salta lo step di ricerca perche l'URL e gia noto (usato dal flow del
|
||||
tab "YouTube Search" dopo che l'utente ha selezionato i risultati).
|
||||
|
||||
Args:
|
||||
urls: lista URL YouTube (parallela a titles)
|
||||
titles: lista titoli video (per logging/dedupe)
|
||||
output_dir: cartella destinazione
|
||||
bitrate: qualita audio (default 320K)
|
||||
cookies_path: file cookies opzionale
|
||||
progress_callback: callback(idx, total, title, status, percent)
|
||||
"""
|
||||
global _current_process
|
||||
reset_stop()
|
||||
total = len(urls)
|
||||
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, (url, title) in enumerate(zip(urls, titles)):
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
key = title or url
|
||||
|
||||
# Salta se gia scaricato (file di tracking)
|
||||
if key in done_set:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "skipped", 100)
|
||||
continue
|
||||
|
||||
# Salta se esiste gia un file corrispondente nella cartella
|
||||
if title and _file_exists_for_query(title, existing_files):
|
||||
_mark_done(done_file, key)
|
||||
done_set.add(key)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "skipped", 100)
|
||||
continue
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "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"),
|
||||
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,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
if is_stopped():
|
||||
_current_process.terminate()
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "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, key, "downloading", pct)
|
||||
|
||||
return_code = _current_process.wait()
|
||||
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
if return_code == 0:
|
||||
_mark_done(done_file, key)
|
||||
done_set.add(key)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "done", 100)
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, 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, key, f"error: {e}", 0)
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def _video_format(quality: str) -> str:
|
||||
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
||||
if not quality or quality == "best":
|
||||
|
||||
+31
-34
@@ -33,11 +33,18 @@ def _bundle_dirs() -> list[Path]:
|
||||
# Directory dell'eseguibile
|
||||
exe_dir = Path(sys.executable).parent
|
||||
dirs.append(exe_dir)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle
|
||||
# ffmpeg.app / ffprobe.app dove sono incapsulati per TCC.
|
||||
if not _IS_WINDOWS:
|
||||
frameworks = exe_dir.parent / "Frameworks"
|
||||
if frameworks.exists():
|
||||
dirs.append(frameworks)
|
||||
# Mini-bundle .app per i subprocess (vedi build_macos.py
|
||||
# wrap_subprocess_in_bundle).
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
sub_macos = frameworks / sub / "Contents" / "MacOS"
|
||||
if sub_macos.exists():
|
||||
dirs.append(sub_macos)
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -96,55 +103,45 @@ def find_ytdlp() -> str:
|
||||
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
|
||||
def _find_binary(name: str) -> Optional[str]:
|
||||
"""Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
|
||||
exe_name = _exe(name)
|
||||
# 1. Bundle (incluso eventuali sub-bundle .app su macOS)
|
||||
for d in _bundle_dirs():
|
||||
if (d / ffmpeg_name).exists():
|
||||
return str(d)
|
||||
|
||||
# 2. Percorsi noti
|
||||
candidate = d / exe_name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
# 2. Percorsi noti per OS
|
||||
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)
|
||||
cand = search / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
else:
|
||||
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
|
||||
if (Path(search_path) / "ffmpeg").exists():
|
||||
return search_path
|
||||
|
||||
cand = Path(search_path) / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
# 3. PATH generico
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
return str(Path(ffmpeg).parent)
|
||||
found = shutil.which(name)
|
||||
return found if found else None
|
||||
|
||||
return None
|
||||
|
||||
def find_ffmpeg_dir() -> Optional[str]:
|
||||
"""Ritorna la directory contenente ffmpeg (utile per PATH env)."""
|
||||
ff = _find_binary("ffmpeg")
|
||||
return str(Path(ff).parent) if ff else 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
|
||||
return _find_binary("ffmpeg")
|
||||
|
||||
|
||||
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
|
||||
return _find_binary("ffprobe")
|
||||
+355
-219
@@ -1,7 +1,10 @@
|
||||
"""Registrazione audio da un dispositivo di input via ffmpeg.
|
||||
"""Registrazione audio.
|
||||
|
||||
macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole)
|
||||
Windows: usa DirectShow
|
||||
macOS: AVCaptureSession via PyObjC dentro al processo Python principale.
|
||||
Il bundle MusicTools.app ha NSMicrophoneUsageDescription e il TCC viene
|
||||
concesso al main, quindi AVFoundation registra senza problemi. ffmpeg
|
||||
viene usato SOLO per convertire il file CAF a MP3 (no microfono = no TCC).
|
||||
Windows: ffmpeg DirectShow (qui il problema TCC non esiste).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,38 +28,33 @@ _IS_WIN = sys.platform == "win32"
|
||||
# ============================================================
|
||||
# Stato globale (un solo recording alla volta)
|
||||
# ============================================================
|
||||
_current_proc: Optional[subprocess.Popen] = None
|
||||
_proc_lock = threading.Lock()
|
||||
_stop_event = threading.Event()
|
||||
_recording_thread: Optional[threading.Thread] = None
|
||||
_recording_lock = threading.Lock()
|
||||
_recording_state: dict = {}
|
||||
_stderr_buf: list = []
|
||||
_stderr_lock = threading.Lock()
|
||||
|
||||
|
||||
def is_recording() -> bool:
|
||||
with _proc_lock:
|
||||
return _current_proc is not None and _current_proc.poll() is None
|
||||
with _recording_lock:
|
||||
return bool(_recording_state.get("active"))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}.
|
||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||
"""Espone le ultime righe di log diagnostico ai fini diagnostici.
|
||||
|
||||
is_virtual = True per driver di loopback noti (BlackHole, Soundflower,
|
||||
Loopback, Audio Hijack, VB-Cable, ...)
|
||||
Su macOS contiene messaggi del backend AVCaptureSession + stderr di
|
||||
ffmpeg durante la conversione CAF->MP3. Su Windows contiene lo
|
||||
stderr di ffmpeg per la registrazione DirectShow.
|
||||
"""
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return []
|
||||
with _stderr_lock:
|
||||
return list(_stderr_buf[-max_lines:])
|
||||
|
||||
if _IS_MAC:
|
||||
return _list_macos(ffmpeg)
|
||||
if _IS_WIN:
|
||||
return _list_windows(ffmpeg)
|
||||
return []
|
||||
|
||||
def _log_diag(line: str) -> None:
|
||||
with _stderr_lock:
|
||||
_stderr_buf.append(line)
|
||||
if len(_stderr_buf) > 200:
|
||||
del _stderr_buf[0]
|
||||
|
||||
|
||||
_VIRTUAL_KEYWORDS = (
|
||||
@@ -71,42 +69,44 @@ def _is_virtual(name: str) -> bool:
|
||||
return any(k in n for k in _VIRTUAL_KEYWORDS)
|
||||
|
||||
|
||||
def _list_macos(ffmpeg: str) -> list[dict]:
|
||||
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
|
||||
L'output va su stderr."""
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
|
||||
Ogni voce: {"id": "...", "name": "...", "is_virtual": bool}.
|
||||
Su macOS l'`id` e' l'uniqueID di AVCaptureDevice (stabile).
|
||||
Su Windows e' il nome del device (richiesto da DirectShow).
|
||||
"""
|
||||
if _IS_MAC:
|
||||
return _list_macos_avf()
|
||||
if _IS_WIN:
|
||||
ffmpeg = find_ffmpeg()
|
||||
return _list_windows(ffmpeg) if ffmpeg else []
|
||||
return []
|
||||
|
||||
|
||||
def _list_macos_avf() -> list[dict]:
|
||||
"""Enumera input audio via AVFoundation (no ffmpeg subprocess)."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
||||
"-list_devices", "true", "-i", ""],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except Exception:
|
||||
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||
except ImportError as e:
|
||||
_log_diag(f"[avf] AVFoundation non disponibile: {e}")
|
||||
return []
|
||||
|
||||
devices: list[dict] = []
|
||||
in_audio = False
|
||||
audio_section_re = re.compile(r"AVFoundation\s+audio\s+devices", re.IGNORECASE)
|
||||
other_section_re = re.compile(r"AVFoundation\s+(?:video)\s+devices", re.IGNORECASE)
|
||||
line_re = re.compile(r"\[(\d+)\]\s+(.+?)\s*$")
|
||||
|
||||
for raw in proc.stderr.splitlines():
|
||||
line = raw.strip()
|
||||
if audio_section_re.search(line):
|
||||
in_audio = True
|
||||
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||
try:
|
||||
name = str(d.localizedName())
|
||||
uid = str(d.uniqueID())
|
||||
except Exception:
|
||||
continue
|
||||
if other_section_re.search(line):
|
||||
in_audio = False
|
||||
continue
|
||||
if not in_audio:
|
||||
continue
|
||||
clean = re.sub(r"^\[AVFoundation[^\]]*\]\s*", "", line)
|
||||
m = line_re.match(clean)
|
||||
if not m:
|
||||
continue
|
||||
idx, name = m.group(1), m.group(2).strip()
|
||||
devices.append({"id": idx, "name": name, "is_virtual": _is_virtual(name)})
|
||||
|
||||
devices.append({
|
||||
"id": uid,
|
||||
"name": name,
|
||||
"is_virtual": _is_virtual(name),
|
||||
})
|
||||
return devices
|
||||
|
||||
|
||||
@@ -150,7 +150,6 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
||||
name = mn.group(1)
|
||||
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
|
||||
|
||||
# Dedup preservando l'ordine
|
||||
seen = set()
|
||||
out = []
|
||||
for d in devices:
|
||||
@@ -162,130 +161,295 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione
|
||||
# Permesso microfono (macOS)
|
||||
# ============================================================
|
||||
def _build_record_cmd(ffmpeg: str, device_id: str, output_path: str,
|
||||
bitrate: str) -> list[str]:
|
||||
"""Comando ffmpeg per registrare su file MP3."""
|
||||
if _IS_MAC:
|
||||
return [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "avfoundation",
|
||||
"-i", f":{device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
output_path,
|
||||
]
|
||||
if _IS_WIN:
|
||||
return [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
output_path,
|
||||
]
|
||||
raise RuntimeError("Sistema operativo non supportato per la registrazione")
|
||||
def _ensure_macos_mic_permission() -> tuple[bool, str]:
|
||||
"""Verifica/richiede il permesso microfono (TCC) per il processo corrente.
|
||||
|
||||
|
||||
def start_recording(
|
||||
device_id: str,
|
||||
output_path: str,
|
||||
bitrate: str = "320k",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
"""Avvia una registrazione in un thread.
|
||||
Ritorna {ok, error?, output_path?}.
|
||||
|
||||
progress_callback(status, payload) viene chiamato con:
|
||||
("started", {"output_path": ...})
|
||||
("tick", {"seconds": int})
|
||||
("stopped", {"output_path": ..., "seconds": int})
|
||||
("error", {"error": "..."})
|
||||
Ritorna (granted, reason).
|
||||
"""
|
||||
global _current_proc, _recording_thread
|
||||
try:
|
||||
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||
except ImportError as e:
|
||||
return False, f"AVFoundation non disponibile: {e}"
|
||||
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
# 0=notDetermined, 1=restricted, 2=denied, 3=authorized
|
||||
status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)
|
||||
if status == 3:
|
||||
return True, "authorized"
|
||||
if status in (1, 2):
|
||||
return False, (
|
||||
"Permesso microfono negato.\n"
|
||||
"Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||
"Attiva MusicTools e riavvia l'app."
|
||||
)
|
||||
# notDetermined -> chiedi il prompt in modo sincrono.
|
||||
ev = threading.Event()
|
||||
granted = [False]
|
||||
|
||||
def cb(ok):
|
||||
granted[0] = bool(ok)
|
||||
ev.set()
|
||||
|
||||
AVCaptureDevice.requestAccessForMediaType_completionHandler_(AVMediaTypeAudio, cb)
|
||||
# PyObjC pumpa il run loop automaticamente in attesa del callback.
|
||||
ev.wait(60)
|
||||
if not granted[0]:
|
||||
return False, "Permesso microfono negato dall'utente."
|
||||
return True, "granted"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione macOS via AVCaptureSession
|
||||
# ============================================================
|
||||
def _start_recording_macos(device_id: str, output_path: str, bitrate: str,
|
||||
progress_callback: Optional[Callable]) -> dict:
|
||||
from AVFoundation import (
|
||||
AVCaptureSession, AVCaptureDevice, AVCaptureDeviceInput,
|
||||
AVCaptureAudioFileOutput, AVMediaTypeAudio,
|
||||
)
|
||||
from Foundation import NSURL, NSObject
|
||||
|
||||
ok, reason = _ensure_macos_mic_permission()
|
||||
if not ok:
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
# Trova il device: prima per uniqueID, poi per nome (fallback)
|
||||
dev = AVCaptureDevice.deviceWithUniqueID_(device_id)
|
||||
if dev is None:
|
||||
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||
if str(d.localizedName()) == device_id:
|
||||
dev = d
|
||||
break
|
||||
if dev is None:
|
||||
return {"ok": False, "error": "Dispositivo audio non trovato."}
|
||||
|
||||
session = AVCaptureSession.alloc().init()
|
||||
inp, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(dev, None)
|
||||
if err is not None:
|
||||
return {"ok": False, "error": f"Errore input: {err.localizedDescription()}"}
|
||||
if not session.canAddInput_(inp):
|
||||
return {"ok": False, "error": "Impossibile aggiungere il device alla session."}
|
||||
session.addInput_(inp)
|
||||
|
||||
out = AVCaptureAudioFileOutput.alloc().init()
|
||||
if not session.canAddOutput_(out):
|
||||
return {"ok": False, "error": "Impossibile aggiungere l'output alla session."}
|
||||
session.addOutput_(out)
|
||||
|
||||
out_path = Path(output_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
caf_path = out_path.with_suffix(".caf")
|
||||
|
||||
# Delegate: aspetta che lo stop produca il file finale
|
||||
class _Delegate(NSObject):
|
||||
def captureOutput_didStartRecordingToOutputFileAtURL_fromConnections_(
|
||||
self, output, url, conns):
|
||||
self.started = True
|
||||
|
||||
def captureOutput_didFinishRecordingToOutputFileAtURL_fromConnections_error_(
|
||||
self, output, url, conns, err):
|
||||
self.done = True
|
||||
self.err = err.localizedDescription() if err is not None else None
|
||||
|
||||
delegate = _Delegate.alloc().init()
|
||||
delegate.started = False
|
||||
delegate.done = False
|
||||
delegate.err = None
|
||||
|
||||
session.startRunning()
|
||||
out.startRecordingToOutputFileURL_outputFileType_recordingDelegate_(
|
||||
NSURL.fileURLWithPath_(str(caf_path)),
|
||||
"com.apple.coreaudio-format",
|
||||
delegate,
|
||||
)
|
||||
|
||||
with _recording_lock:
|
||||
_recording_state.update({
|
||||
"active": True,
|
||||
"session": session,
|
||||
"output": out,
|
||||
"delegate": delegate,
|
||||
"caf_path": str(caf_path),
|
||||
"mp3_path": str(out_path),
|
||||
"bitrate": bitrate,
|
||||
"started_at": time.monotonic(),
|
||||
"progress_cb": progress_callback,
|
||||
})
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out_path)})
|
||||
|
||||
# Tick thread per emettere "seconds" elapsed (la UI lo mostra)
|
||||
threading.Thread(target=_tick_worker, daemon=True).start()
|
||||
|
||||
_log_diag(f"[avf] Recording started -> {caf_path}")
|
||||
return {"ok": True, "output_path": str(out_path)}
|
||||
|
||||
|
||||
def _tick_worker():
|
||||
while is_recording():
|
||||
with _recording_lock:
|
||||
cb = _recording_state.get("progress_cb")
|
||||
started_at = _recording_state.get("started_at", time.monotonic())
|
||||
elapsed = int(time.monotonic() - started_at)
|
||||
if cb:
|
||||
cb("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _stop_recording_macos() -> dict:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
return {"ok": True}
|
||||
state = dict(_recording_state)
|
||||
_recording_state["active"] = False
|
||||
|
||||
session = state["session"]
|
||||
out = state["output"]
|
||||
delegate = state["delegate"]
|
||||
caf_path = Path(state["caf_path"])
|
||||
mp3_path = Path(state["mp3_path"])
|
||||
bitrate = state["bitrate"]
|
||||
cb = state["progress_cb"]
|
||||
started_at = state["started_at"]
|
||||
|
||||
# Stop recording (chiama il delegate didFinish dopo aver finalizzato il file)
|
||||
out.stopRecording()
|
||||
# Aspetta il completamento del file
|
||||
deadline = time.time() + 8
|
||||
while not getattr(delegate, "done", False) and time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
session.stopRunning()
|
||||
|
||||
if getattr(delegate, "err", None):
|
||||
_log_diag(f"[avf] delegate error: {delegate.err}")
|
||||
|
||||
seconds = int(time.monotonic() - started_at)
|
||||
|
||||
# Conversione CAF -> MP3 via ffmpeg (NO microfono: niente TCC)
|
||||
if caf_path.exists():
|
||||
ffmpeg = find_ffmpeg()
|
||||
if ffmpeg:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-y", "-i", str(caf_path),
|
||||
"-acodec", "libmp3lame", "-b:a", bitrate,
|
||||
"-vn", str(mp3_path)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
_log_diag(f"[ffmpeg-convert] rc={proc.returncode}")
|
||||
for ln in (proc.stderr or "").splitlines()[-10:]:
|
||||
_log_diag(ln)
|
||||
# Cleanup CAF temporaneo
|
||||
try:
|
||||
caf_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
_log_diag(f"[ffmpeg-convert] exception: {e}")
|
||||
else:
|
||||
_log_diag("[ffmpeg-convert] ffmpeg non trovato, lascio il file CAF.")
|
||||
# Se ffmpeg non c'e', rinomina il CAF in MP3 (estensione errata
|
||||
# ma l'utente ha qualcosa)
|
||||
try:
|
||||
caf_path.rename(mp3_path.with_suffix(".caf"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if cb:
|
||||
if mp3_path.exists():
|
||||
cb("stopped", {"output_path": str(mp3_path), "seconds": seconds})
|
||||
else:
|
||||
cb("error", {"error": "File registrato non trovato dopo lo stop."})
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione Windows via ffmpeg DirectShow (invariato)
|
||||
# ============================================================
|
||||
def _start_recording_windows(device_id: str, output_path: str, bitrate: str,
|
||||
progress_callback: Optional[Callable]) -> dict:
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return {"ok": False, "error": "ffmpeg non trovato"}
|
||||
|
||||
out = Path(output_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
str(out),
|
||||
]
|
||||
|
||||
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
|
||||
|
||||
_stop_event.clear()
|
||||
with _stderr_lock:
|
||||
_stderr_buf.clear()
|
||||
|
||||
def _drain_stderr(proc):
|
||||
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
|
||||
del pipe e per salvare le ultime righe ai fini diagnostici."""
|
||||
def _drain(proc):
|
||||
try:
|
||||
for raw in iter(proc.stderr.readline, b""):
|
||||
if not raw:
|
||||
break
|
||||
line = raw.decode("utf-8", errors="ignore").rstrip()
|
||||
with _stderr_lock:
|
||||
_stderr_buf.append(line)
|
||||
# Mantieni solo le ultime 50 righe per non sforare la RAM
|
||||
if len(_stderr_buf) > 50:
|
||||
del _stderr_buf[0]
|
||||
_log_diag(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker():
|
||||
global _current_proc
|
||||
try:
|
||||
with _proc_lock:
|
||||
_current_proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
with _recording_lock:
|
||||
_recording_state.update({
|
||||
"active": True,
|
||||
"proc": proc,
|
||||
"output_path": str(out),
|
||||
"started_at": time.monotonic(),
|
||||
"progress_cb": progress_callback,
|
||||
})
|
||||
threading.Thread(target=_drain, args=(proc,), daemon=True).start()
|
||||
|
||||
drain_t = threading.Thread(target=_drain_stderr,
|
||||
args=(_current_proc,), daemon=True)
|
||||
drain_t.start()
|
||||
|
||||
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
|
||||
# primi ~1.5s, segnala errore prima di emettere 'started'.
|
||||
start = time.monotonic()
|
||||
opened = False
|
||||
for _ in range(15):
|
||||
if _current_proc.poll() is not None:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
|
||||
with _stderr_lock:
|
||||
if any("Press [q]" in l for l in _stderr_buf):
|
||||
opened = True
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
rc_early = _current_proc.poll()
|
||||
if not opened and rc_early is not None:
|
||||
# ffmpeg e' uscito prima di iniziare a registrare
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
if not opened and proc.poll() is not None:
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
|
||||
progress_callback("error", {
|
||||
"error": _extract_friendly_error_windows(proc.returncode),
|
||||
})
|
||||
return
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out)})
|
||||
|
||||
while True:
|
||||
if _stop_event.is_set():
|
||||
break
|
||||
rc = _current_proc.poll()
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
break
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
break
|
||||
elapsed = int(time.monotonic() - start)
|
||||
@@ -293,123 +457,95 @@ def start_recording(
|
||||
progress_callback("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
# Stop gracefully
|
||||
if _current_proc.poll() is None:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
_current_proc.stdin.write(b"q\n")
|
||||
_current_proc.stdin.flush()
|
||||
proc.stdin.write(b"q\n")
|
||||
proc.stdin.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_current_proc.wait(timeout=5)
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.terminate()
|
||||
proc.terminate()
|
||||
try:
|
||||
_current_proc.wait(timeout=3)
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
_current_proc.kill()
|
||||
proc.kill()
|
||||
|
||||
rc = _current_proc.returncode
|
||||
seconds = int(time.monotonic() - start)
|
||||
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
|
||||
if progress_callback:
|
||||
if rc == 0 or out.exists():
|
||||
if proc.returncode == 0 or out.exists():
|
||||
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
||||
else:
|
||||
progress_callback("error", {"error": _extract_friendly_error(rc)})
|
||||
progress_callback("error", {
|
||||
"error": _extract_friendly_error_windows(proc.returncode),
|
||||
})
|
||||
except Exception as e:
|
||||
with _proc_lock:
|
||||
_current_proc = None
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": str(e)})
|
||||
|
||||
_recording_thread = threading.Thread(target=_worker, daemon=True)
|
||||
_recording_thread.start()
|
||||
progress_callback("error", {"error": f"{type(e).__name__}: {e}"})
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
return {"ok": True, "output_path": str(out)}
|
||||
|
||||
|
||||
def _extract_friendly_error(rc: int) -> str:
|
||||
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
|
||||
Riconosce i casi tipici: permessi microfono, device occupato,
|
||||
device non disponibile, clock drift di BlackHole, ecc.
|
||||
|
||||
Fallback: se non riconosce il pattern, ritorna le ultime righe
|
||||
significative dello stderr cosi' l'utente puo' diagnosticare
|
||||
o aprire un ticket.
|
||||
"""
|
||||
def _extract_friendly_error_windows(rc: int) -> str:
|
||||
with _stderr_lock:
|
||||
lines = list(_stderr_buf)
|
||||
|
||||
text = "\n".join(lines).lower()
|
||||
is_mac = sys.platform == "darwin"
|
||||
|
||||
# ---- Errori specifici (priorita' alta) ----
|
||||
|
||||
if "input/output error" in text or "errno 22" in text:
|
||||
return (
|
||||
"Dispositivo audio non pronto (BlackHole o scheda virtuale). "
|
||||
"Spesso si risolve cosi':\n"
|
||||
" 1) Smetti la registrazione e riprova fra 5 secondi\n"
|
||||
" 2) Se persiste, da Terminale: 'sudo killall coreaudiod'\n"
|
||||
" 3) Verifica che nessun'altra app stia gia' registrando il device"
|
||||
)
|
||||
|
||||
if "device not configured" in text or "device not available" in text:
|
||||
return "Dispositivo non configurato. Premi 'Aggiorna' e riseleziona."
|
||||
|
||||
if "permission" in text or "not authorized" in text or "not permitted" in text \
|
||||
or "tcc" in text or "denied" in text:
|
||||
return _permission_message()
|
||||
|
||||
if "no such device" in text or "no such audio device" in text or "invalid device" in text:
|
||||
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona dalla lista."
|
||||
|
||||
# ---- Macro per rc=-6 (SIGABRT) su macOS ----
|
||||
# Su macOS rc=-6 e' quasi sempre il sintomo di un crash di ffmpeg
|
||||
# dovuto al permesso Microfono mancante. La parola 'abort' non
|
||||
# sempre compare nello stderr di ffmpeg recenti.
|
||||
if rc == -6 and is_mac:
|
||||
return _permission_message()
|
||||
|
||||
# ---- Fallback: cerca le ultime righe significative ----
|
||||
if "no such device" in text or "could not find" in text:
|
||||
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona."
|
||||
if "permission" in text or "denied" in text:
|
||||
return "Permesso microfono mancante. Impostazioni Windows -> Privacy -> Microfono."
|
||||
for line in reversed(lines):
|
||||
l = line.strip()
|
||||
if not l:
|
||||
continue
|
||||
low = l.lower()
|
||||
# Riga AVFoundation/dshow specifica (es. "[avfoundation @ 0x...] Could not...")
|
||||
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
|
||||
if "error" in low or "fail" in low or "denied" in low:
|
||||
return l
|
||||
if "error" in low or "fail" in low or "denied" in low or "cannot" in low:
|
||||
return l
|
||||
|
||||
# Ultima spiaggia: rc + ultime 3 righe di stderr per debug
|
||||
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
|
||||
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
|
||||
|
||||
|
||||
def _permission_message() -> str:
|
||||
"""Messaggio standardizzato per il problema permesso Microfono macOS."""
|
||||
return (
|
||||
"Permesso Microfono mancante. Su macOS:\n"
|
||||
" Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||
" Abilita 'Terminal' (se lanci l'app da terminale) o 'MusicTools'\n"
|
||||
" (se usi l'app installata). Poi riavvia l'app."
|
||||
)
|
||||
def _stop_recording_windows() -> dict:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
return {"ok": True}
|
||||
# Il worker thread vede 'active'=False e termina ffmpeg con 'q'.
|
||||
_recording_state["active"] = False
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||
"""Espone le ultime righe di stderr ai fini diagnostici.
|
||||
Chiamato dal bridge per la UI 'Mostra log tecnici'."""
|
||||
# ============================================================
|
||||
# API pubblica
|
||||
# ============================================================
|
||||
def start_recording(
|
||||
device_id: str,
|
||||
output_path: str,
|
||||
bitrate: str = "320k",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
|
||||
with _stderr_lock:
|
||||
return list(_stderr_buf[-max_lines:])
|
||||
_stderr_buf.clear()
|
||||
|
||||
if _IS_MAC:
|
||||
return _start_recording_macos(device_id, output_path, bitrate, progress_callback)
|
||||
if _IS_WIN:
|
||||
return _start_recording_windows(device_id, output_path, bitrate, progress_callback)
|
||||
return {"ok": False, "error": "Sistema operativo non supportato per la registrazione"}
|
||||
|
||||
|
||||
def stop_recording() -> dict:
|
||||
"""Ferma la registrazione corrente (se attiva)."""
|
||||
_stop_event.set()
|
||||
if _IS_MAC:
|
||||
return _stop_recording_macos()
|
||||
if _IS_WIN:
|
||||
return _stop_recording_windows()
|
||||
return {"ok": True}
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@@ -255,3 +257,156 @@ def resolve_spotify_track_no_auth(url: str) -> tuple[str, str, list[dict]]:
|
||||
|
||||
track = {"name": name, "artist": artist}
|
||||
return "Brano", name, [track]
|
||||
|
||||
|
||||
def search_track(token: str, query: str):
|
||||
"""Cerca un brano su Spotify e ritorna il primo match.
|
||||
|
||||
Ritorna un dict con {id, url, name, artists} oppure None se nessun match.
|
||||
Solleva requests.HTTPError su errori server / auth.
|
||||
"""
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"q": query, "type": "track", "limit": 1},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
items = resp.json().get("tracks", {}).get("items", [])
|
||||
if not items:
|
||||
return None
|
||||
t = items[0]
|
||||
return {
|
||||
"id": t.get("id"),
|
||||
"url": t.get("external_urls", {}).get("spotify", ""),
|
||||
"name": t.get("name", ""),
|
||||
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])),
|
||||
}
|
||||
|
||||
|
||||
def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||
"""Cerca brani su Spotify e ritorna una lista di dict.
|
||||
|
||||
Args:
|
||||
token: access token Spotify
|
||||
query: query libera (titolo, artista, misto)
|
||||
limit: max risultati (Spotify cap = 50)
|
||||
|
||||
Returns:
|
||||
list[dict] con {id, url, name, artists, album, duration_sec}. Vuota se nessun match.
|
||||
"""
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"q": query, "type": "track", "limit": min(limit, 50)},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
items = resp.json().get("tracks", {}).get("items", [])
|
||||
return [_track_to_dict(t) for t in items]
|
||||
|
||||
|
||||
def _track_to_dict(t: dict) -> dict:
|
||||
"""Mappa il track object Spotify sul nostro schema uniforme."""
|
||||
images = t.get("album", {}).get("images", []) or []
|
||||
# Spotify torna 3 taglie ordinate large->small. Prendo la più piccola (~64px)
|
||||
# se disponibile, altrimenti la prima che c'è.
|
||||
image_url = ""
|
||||
if images:
|
||||
image_url = images[-1].get("url", "") or images[0].get("url", "")
|
||||
return {
|
||||
"id": t.get("id", ""),
|
||||
"url": t.get("external_urls", {}).get("spotify", ""),
|
||||
"name": t.get("name", ""),
|
||||
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])),
|
||||
"album": t.get("album", {}).get("name", ""),
|
||||
"duration_sec": int(t.get("duration_ms", 0)) // 1000,
|
||||
"image_url": image_url,
|
||||
}
|
||||
|
||||
|
||||
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||
"""Trova l'artista esatto (o il piu' popolare tra i match) e ritorna
|
||||
tutti i suoi brani: top tracks + tracce di ogni album/single.
|
||||
Deduplica per (name.lower().strip(), first_artist.lower().strip()).
|
||||
|
||||
Raises:
|
||||
ValueError: se nessun artista trovato per il nome dato.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 1. Cerca artista
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers=headers,
|
||||
params={"q": artist_name, "type": "artist", "limit": 5},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
candidates = resp.json().get("artists", {}).get("items", [])
|
||||
if not candidates:
|
||||
raise ValueError(f"Artista '{artist_name}' non trovato")
|
||||
|
||||
# Match esatto (case-insensitive) se possibile, altrimenti piu' popolare
|
||||
query_lower = artist_name.lower().strip()
|
||||
exact = [c for c in candidates if c.get("name", "").lower().strip() == query_lower]
|
||||
if exact:
|
||||
artist = exact[0]
|
||||
else:
|
||||
artist = max(candidates, key=lambda c: c.get("popularity", 0))
|
||||
artist_id = artist["id"]
|
||||
|
||||
collected: list = []
|
||||
|
||||
# 2. Top tracks
|
||||
r_top = requests.get(
|
||||
f"https://api.spotify.com/v1/artists/{artist_id}/top-tracks",
|
||||
headers=headers,
|
||||
params={"market": "IT"},
|
||||
timeout=15,
|
||||
)
|
||||
r_top.raise_for_status()
|
||||
for t in r_top.json().get("tracks", []):
|
||||
collected.append(_track_to_dict(t))
|
||||
|
||||
# 3. Albums (album + single)
|
||||
r_alb = requests.get(
|
||||
f"https://api.spotify.com/v1/artists/{artist_id}/albums",
|
||||
headers=headers,
|
||||
params={"include_groups": "album,single", "limit": 50, "market": "IT"},
|
||||
timeout=15,
|
||||
)
|
||||
r_alb.raise_for_status()
|
||||
albums = r_alb.json().get("items", [])
|
||||
|
||||
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola
|
||||
# includendo anche le images dell'album così _track_to_dict riesce a estrarre la cover)
|
||||
for alb in albums:
|
||||
alb_id = alb.get("id")
|
||||
alb_name = alb.get("name", "")
|
||||
alb_images = alb.get("images", []) or []
|
||||
if not alb_id:
|
||||
continue
|
||||
time.sleep(0.1) # rate limit interno
|
||||
r_at = requests.get(
|
||||
f"https://api.spotify.com/v1/albums/{alb_id}/tracks",
|
||||
headers=headers,
|
||||
params={"limit": 50},
|
||||
timeout=15,
|
||||
)
|
||||
r_at.raise_for_status()
|
||||
for t in r_at.json().get("items", []):
|
||||
t = dict(t)
|
||||
t.setdefault("album", {"name": alb_name, "images": alb_images})
|
||||
collected.append(_track_to_dict(t))
|
||||
|
||||
# 5. Dedupe
|
||||
seen: set = set()
|
||||
unique: list = []
|
||||
for t in collected:
|
||||
key = (t["name"].lower().strip(), t["artists"].split(",")[0].lower().strip())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(t)
|
||||
return unique
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Ricerca su YouTube via yt-dlp (subprocess, flat playlist metadata)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from core.paths import find_ytdlp, subprocess_flags
|
||||
|
||||
|
||||
_TIMEOUT_SEC = 30
|
||||
|
||||
|
||||
def search_youtube(query: str, limit: int = 50) -> list:
|
||||
"""Cerca su YouTube (ytsearchN:query) e ritorna metadata "flat" (senza scaricare).
|
||||
|
||||
Args:
|
||||
query: testo di ricerca libero
|
||||
limit: numero massimo di risultati (default 50)
|
||||
|
||||
Returns:
|
||||
list[dict] con {id, url, title, channel, duration_sec}. Vuota se nessun match.
|
||||
|
||||
Raises:
|
||||
RuntimeError: se yt-dlp non disponibile, timeout, o exit non-zero.
|
||||
"""
|
||||
ytdlp = find_ytdlp()
|
||||
if not ytdlp:
|
||||
raise RuntimeError("yt-dlp non trovato nel bundle / PATH")
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--flat-playlist",
|
||||
"--dump-single-json",
|
||||
"--no-warnings",
|
||||
f"ytsearch{limit}:{query}",
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TIMEOUT_SEC,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError(f"Timeout ricerca YouTube ({_TIMEOUT_SEC}s)") from e
|
||||
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or "").strip().splitlines()[-3:]
|
||||
raise RuntimeError(f"yt-dlp error: {' | '.join(tail) or 'unknown'}")
|
||||
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"yt-dlp output non-JSON: {e}") from e
|
||||
|
||||
entries = data.get("entries") or []
|
||||
result: list = []
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
video_id = e.get("id") or ""
|
||||
url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "")
|
||||
image_url = f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
|
||||
result.append({
|
||||
"id": video_id,
|
||||
"url": url,
|
||||
"title": e.get("title") or "",
|
||||
"channel": e.get("uploader") or e.get("channel") or "",
|
||||
"duration_sec": int(e.get("duration") or 0),
|
||||
"image_url": image_url,
|
||||
})
|
||||
return result
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,325 @@
|
||||
# Beatport Charts — Design Spec
|
||||
|
||||
- **Data:** 2026-07-14
|
||||
- **Autore:** LuZa + Claude
|
||||
- **Stato:** Approvato, pronto per implementation plan
|
||||
- **Target release:** MusicTools v1.8.0
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Aggiungere una nuova tab all'app desktop MusicTools che permetta di:
|
||||
|
||||
1. Scegliere un genere musicale da un elenco di ~30 generi Beatport
|
||||
2. Caricare la classifica **Top 100** ufficiale di quel genere dal sito Beatport
|
||||
3. Visualizzare i 100 brani in tabella con checkbox per scegliere quali scaricare
|
||||
4. Scaricare i brani selezionati riusando il flusso esistente Spotify search → yt-dlp
|
||||
|
||||
Sostituisce il lavoro manuale attuale (l'utente copia/incolla la Top 100 in un file `.txt` che poi passa al downloader).
|
||||
|
||||
## Non-goals
|
||||
|
||||
Esplicitamente **fuori scope** in questa versione:
|
||||
|
||||
- Preview audio dei brani (snippet Beatport sono DRM protetti)
|
||||
- Chart diverse dalla Top 100 (Hype 100, DJ Charts, weekly picks) — rinviato a v2
|
||||
- Ricerca / filtro nella tabella (100 righe si scorrono senza)
|
||||
- Refresh automatico della classifica
|
||||
- Esportazione della lista come file `.txt`
|
||||
- Auto-update della mappa dei generi (resta hardcoded, aggiornata a ogni release)
|
||||
- Beatport account / login / preferiti utente
|
||||
|
||||
## Approccio scelto — Parse `__NEXT_DATA__`
|
||||
|
||||
Beatport è costruito su Next.js. Ogni pagina di classifica include un tag `<script id="__NEXT_DATA__" type="application/json">…</script>` con il payload completo della pagina in JSON strutturato: id, artist, title, mix, duration, chart position, label, key, BPM. Il parser:
|
||||
|
||||
1. `GET https://www.beatport.com/genre/<slug>/<id>/top-100` con User-Agent browser
|
||||
2. `re.search(r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>', html)` per estrarre il JSON
|
||||
3. `json.loads` + walk fino ai 100 track object
|
||||
4. Mappa in dataclass `BeatportTrack`
|
||||
|
||||
**Vantaggi:** dati strutturati, robusto ai cambi di layout CSS, un solo GET per 100 brani.
|
||||
|
||||
**Alternative scartate:**
|
||||
- BeautifulSoup su CSS class → i class name Next.js sono hash generati, si rompono a ogni build Beatport
|
||||
- API interna `api.beatport.com` → richiede OAuth token, User-Agent specifici, ToS espressamente vietano l'uso non autorizzato → rischio ban
|
||||
|
||||
## Architettura
|
||||
|
||||
### Nuovi moduli
|
||||
|
||||
#### `core/beatport.py` (nuovo)
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
GENRES: dict[str, tuple[int, str]] = {
|
||||
"melodic-house-techno": (90, "Melodic House & Techno"),
|
||||
"techno-peak-time-driving": (6, "Techno (Peak Time / Driving)"),
|
||||
"techno-raw-deep-hypnotic": (92, "Techno (Raw / Deep / Hypnotic)"),
|
||||
"deep-house": (12, "Deep House"),
|
||||
"tech-house": (11, "Tech House"),
|
||||
"progressive-house": (15, "Progressive House"),
|
||||
"house": (5, "House"),
|
||||
"afro-house": (89, "Afro House"),
|
||||
"organic-house-downtempo": (93, "Organic House / Downtempo"),
|
||||
"trance": (7, "Trance"),
|
||||
"drum-bass": (1, "Drum & Bass"),
|
||||
"dubstep": (18, "Dubstep"),
|
||||
"minimal-deep-tech": (14, "Minimal / Deep Tech"),
|
||||
"indie-dance": (37, "Indie Dance"),
|
||||
# NOTA: la mappa completa (~30 slug/id) va enumerata in fase di
|
||||
# implementazione recuperando gli slug + numeric id dalla pagina
|
||||
# https://www.beatport.com/genres — lo script che li estrae va
|
||||
# committato in `scripts/refresh_beatport_genres.py` per potervi
|
||||
# rifare la refresh a mano quando Beatport aggiunge/rimuove generi.
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BeatportTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str
|
||||
artists: str # "A, B & C" già formattato
|
||||
duration_sec: int
|
||||
beatport_id: int
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
"""Formato compatibile con i file .txt esistenti:
|
||||
'Artista – Titolo (Mix) (M:SS)'"""
|
||||
m, s = divmod(self.duration_sec, 60)
|
||||
return f"{self.artists} – {self.title} ({self.mix}) ({m}:{s:02d})"
|
||||
|
||||
@property
|
||||
def spotify_query(self) -> str:
|
||||
return f"{self.artists} {self.title}"
|
||||
|
||||
|
||||
class BeatportError(Exception): pass
|
||||
class BeatportUnreachableError(BeatportError): pass
|
||||
class BeatportParseError(BeatportError): pass
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list[BeatportTrack]:
|
||||
"""Fetches Top 100 per il genere. Cache in-memory 15 min.
|
||||
Solleva ValueError se slug non valido, BeatportUnreachableError su rete,
|
||||
BeatportParseError su schema cambiato."""
|
||||
...
|
||||
|
||||
def list_genres() -> list[dict]:
|
||||
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||
...
|
||||
```
|
||||
|
||||
Cache in-memory: `dict[slug, tuple[timestamp, list[BeatportTrack]]]`, TTL 900s.
|
||||
|
||||
Retry: 2 tentativi con backoff 1s, 3s.
|
||||
|
||||
User-Agent: `"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"` (aggiornato per release).
|
||||
|
||||
#### `main.py` — nuove API pywebview esposte al JS
|
||||
|
||||
```python
|
||||
class Api:
|
||||
# …esistenti
|
||||
|
||||
def beatport_genres(self) -> list[dict]:
|
||||
return beatport.list_genres()
|
||||
|
||||
def beatport_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
|
||||
try:
|
||||
tracks = beatport.fetch_top100(slug, force_refresh=force_refresh)
|
||||
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "invalid_genre", "message": str(e)}
|
||||
except BeatportUnreachableError as e:
|
||||
return {"ok": False, "error": "unreachable", "message": str(e)}
|
||||
except BeatportParseError as e:
|
||||
return {"ok": False, "error": "parse", "message": str(e)}
|
||||
|
||||
def beatport_check_existing(self, tracks: list[dict], genre_name: str) -> list[bool]:
|
||||
"""Ritorna una lista di boolean: True se il file esiste già in output_dir/Beatport/<Genre>/."""
|
||||
...
|
||||
|
||||
def beatport_download_selected(
|
||||
self,
|
||||
tracks: list[dict],
|
||||
genre_name: str,
|
||||
) -> None:
|
||||
"""Loop: per ogni track, spotify search → downloader.
|
||||
Emette log/progress sugli stessi eventi degli altri download."""
|
||||
...
|
||||
```
|
||||
|
||||
### Riuso codice esistente
|
||||
|
||||
- `core/spotify_client.py`: usa `search_track(query) -> Optional[str]` per ottenere lo Spotify URI (o il track object). **Da verificare in fase di implementazione** se il metodo esiste già come funzione pubblica riutilizzabile — se no, estrai la logica di search dal path esistente `download_from_spotify_url` in una funzione pubblica dedicata (refactor mirato, no cambi di comportamento per gli altri consumatori).
|
||||
- `core/downloader.py`: usa il path Spotify URI → yt-dlp esistente. `request_stop()` / `reset_stop()` già disponibili.
|
||||
- `webui/js/app.js`: riusa il pattern log/progress già in uso per le altre tab.
|
||||
- `core/config.py`: aggiunge un solo nuovo campo `beatport_last_genre: str` ai DEFAULTS.
|
||||
|
||||
### Frontend
|
||||
|
||||
**Nuova tab in `webui/index.html`:**
|
||||
|
||||
```html
|
||||
<button data-tab="beatport" class="tab-btn">🎧 Beatport</button>
|
||||
<section id="tab-beatport" class="tab-panel">
|
||||
<div class="beatport-header">
|
||||
<label>Genere:
|
||||
<select id="beatport-genre"></select>
|
||||
</label>
|
||||
<button id="beatport-load">Carica Top 100</button>
|
||||
<div id="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div id="beatport-status"></div>
|
||||
|
||||
<table id="beatport-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="beatport-select-all"></th>
|
||||
<th>#</th><th>Artista</th><th>Titolo</th><th>Durata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="beatport-toolbar" hidden>
|
||||
<span id="beatport-selected-count">0/0 selezionati</span>
|
||||
<button id="beatport-download">⬇ Scarica selezionati</button>
|
||||
<button id="beatport-stop" hidden>■ Interrompi</button>
|
||||
</div>
|
||||
|
||||
<div id="beatport-log" class="log-area"></div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**JavaScript in `webui/js/app.js`:** funzioni `beatportInit()`, `beatportLoadChart()`, `beatportRenderTable(tracks)`, `beatportUpdateSelection()`, `beatportStartDownload()`. Stato locale: `let currentChart = { slug, name, tracks: [] }`.
|
||||
|
||||
### Persistenza
|
||||
|
||||
- Ultimo genere selezionato → `config.beatport_last_genre`
|
||||
- File scaricati → rilevati con `os.path.exists()` sul filename generato dal downloader (stesso pattern degli altri tab)
|
||||
- Cache classifica → solo in-memory, si perde a restart (voluto)
|
||||
|
||||
### Percorso output
|
||||
|
||||
`{output_dir}/Beatport/{Genre Display Name}/`
|
||||
|
||||
Es. `/Users/luciano/MUSICA/Beatport/Melodic House & Techno/Kapuchon - Hot Sauce (Extended).mp3`
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
[JS] User seleziona genere, click "Carica Top 100"
|
||||
↓
|
||||
[JS] api.beatport_fetch_chart("melodic-house-techno")
|
||||
↓
|
||||
[PY] core/beatport.fetch_top100():
|
||||
├─ Cache hit (< 15 min)? → ritorna cached
|
||||
└─ GET Beatport, estrai __NEXT_DATA__, parse, mappa in BeatportTrack[]
|
||||
↓
|
||||
[JS] Riceve lista, chiama api.beatport_check_existing() per pre-deselezionare i già-scaricati
|
||||
↓
|
||||
[JS] Renderizza tabella
|
||||
↓
|
||||
[JS] User seleziona/deseleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.beatport_download_selected(tracks_selected, genre_name)
|
||||
↓
|
||||
[PY] Loop su tracks:
|
||||
├─ emit log "[i/N] Cerco su Spotify: <artista – titolo>"
|
||||
├─ spotify_uri = spotify_client.search_track(track.spotify_query)
|
||||
├─ Se None: emit ✗, continua
|
||||
├─ emit log "[i/N] Download da YouTube…"
|
||||
├─ downloader.download_from_spotify_uri(uri, output_dir=<beatport-subdir>)
|
||||
├─ emit log "[i/N] ✓" o "[i/N] ✗ <motivo>"
|
||||
└─ Se stop_event.is_set(): break
|
||||
```
|
||||
|
||||
## Matrice errori & recovery
|
||||
|
||||
| Errore | Dove | Cosa succede |
|
||||
|---|---|---|
|
||||
| Beatport 5xx / timeout | fetch_top100 | Retry ×2 con backoff 1s, 3s. Poi solleva `BeatportUnreachableError`. UI: banner rosso "Beatport irraggiungibile. Riprova." con retry |
|
||||
| `__NEXT_DATA__` mancante / schema cambiato | fetch_top100 | Dump primi 500 char HTML in `~/Library/Logs/MusicTools/beatport-<ts>.log`, solleva `BeatportParseError`. UI: banner arancione "Impossibile leggere la classifica. Segnala il bug." |
|
||||
| Genere non valido | fetch_top100 | `ValueError` early, nessuna richiesta di rete |
|
||||
| Spotify search no match | download loop | Log riga singola, marca riga tabella ✗ "non trovato", continua |
|
||||
| Spotify creds mancanti | download loop, primo brano | Interrompe subito, banner giallo con link a tab Impostazioni |
|
||||
| yt-dlp fallisce | download loop | Log riga singola ✗, continua con il resto |
|
||||
| Cartella output non scrivibile | pre-check prima del loop | Errore + banner rosso, non parte |
|
||||
| User preme Stop | download loop | `stop_event.set()`, si ferma dopo il brano corrente |
|
||||
| Cache stale | fetch_top100 | Shift+click su "Carica" invalida (`force_refresh=True`); comunque expira dopo 15 min |
|
||||
|
||||
## Rispetto Beatport / rate limiting
|
||||
|
||||
- Un solo GET per classifica (100 brani in un JSON)
|
||||
- Cache 15 min → max 4 richieste/ora anche con click ripetuti
|
||||
- User-Agent browser, no scraping massivo
|
||||
- Zero rischio ban IP in uso normale
|
||||
|
||||
## Licenza / piani
|
||||
|
||||
La feature è un audio download a tutti gli effetti → il `daily_limit` del piano si applica automaticamente (ogni brano scaricato conta come 1). Il piano `basic` con limite 10/giorno può scaricare al massimo 10 brani dalla Top 100. **Nessuna nuova feature-flag in `server/src/plans.js`.** Il gating avviene già nel downloader esistente.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests — `tests/test_beatport.py`
|
||||
|
||||
Nessuna rete durante i test. Fixture: snapshot HTML reale della pagina Beatport in `tests/fixtures/beatport_melodic_top100.html`.
|
||||
|
||||
| Test | Verifica |
|
||||
|---|---|
|
||||
| `test_parse_next_data_extracts_100_tracks` | Il parser trova esattamente 100 track dall'HTML fixture |
|
||||
| `test_track_shape` | Ogni track ha position, title, mix, artists, duration_sec, beatport_id |
|
||||
| `test_positions_sequential` | Posizioni 1..100 senza buchi |
|
||||
| `test_display_format_matches_txt_files` | `BeatportTrack.display` produce esattamente il formato dei file `.txt` attuali |
|
||||
| `test_parse_missing_next_data_raises` | HTML senza `<script id="__NEXT_DATA__">` → `BeatportParseError` |
|
||||
| `test_parse_malformed_json_raises` | `__NEXT_DATA__` con JSON rotto → `BeatportParseError` |
|
||||
| `test_parse_schema_change_raises` | JSON valido ma senza `results[]` → `BeatportParseError` con contesto |
|
||||
| `test_cache_hit_second_call` | 2 chiamate ravvicinate → una sola richiesta HTTP simulata |
|
||||
| `test_cache_expires_after_15min` | Freeze time + 16min → nuova richiesta |
|
||||
| `test_force_refresh_bypasses_cache` | `force_refresh=True` ignora cache anche fresca |
|
||||
| `test_invalid_genre_slug_early_error` | Slug non in `GENRES` → `ValueError` senza toccare la rete |
|
||||
|
||||
### Test HTTP mockato
|
||||
|
||||
`responses` (o `httpx` mock) per verificare timeout, retry policy, User-Agent inviato.
|
||||
|
||||
### Test manuale end-to-end (obbligatorio prima del release)
|
||||
|
||||
1. Avvia MusicTools, apri tab Beatport
|
||||
2. Seleziona "Melodic House & Techno" → click "Carica Top 100" → tabella si popola in <3s
|
||||
3. Deseleziona 90 brani, lascia i primi 10 → click "Scarica selezionati (10)"
|
||||
4. Verifica file .mp3 in `{output_dir}/Beatport/Melodic House & Techno/`, metadata Spotify complete
|
||||
5. Ricarica lo stesso genere → cache hit (nessuna nuova request osservabile in dev tools)
|
||||
6. Cambia genere → nuova fetch
|
||||
7. Interrompi a metà download → si ferma pulito dopo il brano corrente
|
||||
8. Riavvia app → dropdown ricorda l'ultimo genere
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Branch `feat/beatport-charts` (opzionale — la feature è additive, zero rischi per il resto)
|
||||
2. Bump `core/config.py` VERSION → `v1.8.0`
|
||||
3. Note release in `/tmp/notes-v1.8.0.md`:
|
||||
> **Nuova tab Beatport 🎧** — carica le Top 100 per genere direttamente da Beatport e scarica i brani in un click. 30+ generi disponibili.
|
||||
4. Commit + tag `v1.8.0` → CI builda macOS + Windows come da flusso standard
|
||||
5. Zero cambi lato server (`~/api/`), zero migration DB
|
||||
|
||||
## Struttura file impattati
|
||||
|
||||
**Nuovi:**
|
||||
- `core/beatport.py`
|
||||
- `tests/test_beatport.py`
|
||||
- `tests/fixtures/beatport_melodic_top100.html`
|
||||
|
||||
**Modificati:**
|
||||
- `main.py` — 4 nuovi metodi `Api.beatport_*`
|
||||
- `core/config.py` — VERSION bump + campo `beatport_last_genre` in DEFAULTS
|
||||
- `core/spotify_client.py` — espone `search_track()` come funzione pubblica se non lo è già
|
||||
- `webui/index.html` — nuovo tab button + section
|
||||
- `webui/js/app.js` — funzioni `beatport*`
|
||||
- `webui/css/*.css` — stile tabella e toolbar (riuso classi esistenti dove possibile)
|
||||
- `requirements.txt` — nessuna nuova dipendenza runtime (`requests` è già presente per gli altri client HTTP; `__NEXT_DATA__` si estrae con `re.search` + `json.loads`, no BeautifulSoup necessario). Solo dev dep: `responses` per i test HTTP mockati se non già presente
|
||||
@@ -0,0 +1,260 @@
|
||||
# Music Search (Spotify + YouTube) — Design Spec
|
||||
|
||||
- **Data:** 2026-07-14
|
||||
- **Autore:** LuZa + Claude
|
||||
- **Stato:** Approvato, pronto per implementation plan
|
||||
- **Target release:** MusicTools v1.8.1
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Aggiungere due tab distinte all'app che permettano di **cercare brani/artisti/parole libere** e scaricarli:
|
||||
|
||||
1. **Tab 🟢 Spotify** — ricerca via Spotify API (canonica, metadata pulita). Toggle "Solo artista" per ottenere tutta la discografia di un artista invece della ricerca libera.
|
||||
2. **Tab ▶ YouTube** — ricerca diretta via `yt-dlp ytsearch:` (grezza, senza distinzione artista/album, ma copre mix DJ, unreleased, bootleg, live, video-only content).
|
||||
|
||||
Il download in entrambi i casi riusa la pipeline esistente (`start_tracks_download` in `api/bridge.py`) con licenza gate `_gate("audio")`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Un'unica tab "Cerca" con radio Spotify/YouTube (scartato: UX molto diversa per le due sorgenti)
|
||||
- Ricerca combinata Spotify+YouTube in un'unica lista (nessuna deduplica cross-source affidabile)
|
||||
- Playlist temporanee salvabili
|
||||
- Preview audio
|
||||
- Filtri per genere/anno/BPM (non richiesto, YAGNI)
|
||||
- Ricerca album, artisti, playlist su Spotify (limitata a track)
|
||||
- Autocomplete / suggestions come digiti
|
||||
|
||||
## Approcci scelti
|
||||
|
||||
### Spotify
|
||||
|
||||
- Free-form search: `GET /v1/search?q=<query>&type=track&limit=50`
|
||||
- Ritorna fino a 50 track ordinati per rilevanza/popolarità (default Spotify)
|
||||
- Query può essere qualsiasi cosa: titolo, artista, "artista - titolo", parola singola
|
||||
- Artist-mode (toggle attivo):
|
||||
1. `GET /v1/search?q=<query>&type=artist&limit=5` → prendi il match con `name.lower() == query.lower()` altrimenti il più popolare
|
||||
2. `GET /v1/artists/{id}/top-tracks?market=IT` → ~10 top track
|
||||
3. `GET /v1/artists/{id}/albums?include_groups=album,single&limit=50&market=IT` → lista album
|
||||
4. Per ogni album (max 50): `GET /v1/albums/{id}/tracks?limit=50` → tracce
|
||||
5. Deduplica su `(name.lower().strip() + '|' + first_artist.lower().strip())`
|
||||
6. Rate limit interno: `time.sleep(0.1)` tra chiamate `/albums/{id}/tracks`
|
||||
7. Total: ~100-500 track per artista prolifico
|
||||
|
||||
**Vantaggi:** metadata pulita, download pipeline già rodata (Spotify→YouTube via yt-dlp).
|
||||
|
||||
### YouTube
|
||||
|
||||
- `yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"` → JSON con `entries[]`
|
||||
- Per ogni entry: `{title, uploader/channel, duration, id, url}`
|
||||
- Nessuna modalità artista (YouTube search non ha channel-exact disambiguation affidabile)
|
||||
|
||||
**Vantaggi:** trova ciò che Spotify non ha (mix, unreleased, bootleg, live).
|
||||
|
||||
## Architettura
|
||||
|
||||
### Nuovi moduli / estensioni
|
||||
|
||||
#### `core/spotify_client.py` (estensione)
|
||||
|
||||
```python
|
||||
def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||
"""Ricerca free-form. Ritorna list[dict] con {id, url, name, artists, album, duration_sec}."""
|
||||
|
||||
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||
"""Trova l'artista esatto e ritorna tutti i suoi brani (top tracks + tracce da album/singles).
|
||||
Deduplica per (title, first_artist) normalizzato. Solleva ValueError se nessun artista trovato."""
|
||||
```
|
||||
|
||||
Nota: la funzione `search_track` (singolare) aggiunta in Task 7 di Beatport resta com'è per retro-compatibilità. La nuova `search_tracks` (plurale, con limit) è quella usata da questa feature.
|
||||
|
||||
#### `core/youtube_search.py` (nuovo)
|
||||
|
||||
```python
|
||||
def search_youtube(query: str, limit: int = 50) -> list:
|
||||
"""Cerca su YouTube via `yt-dlp ytsearchN:query`.
|
||||
Ritorna list[dict] con {id, url, title, channel, duration_sec}."""
|
||||
```
|
||||
|
||||
Riusa `find_ytdlp()` e `subprocess_flags()` da `core/paths.py`. Timeout subprocess 30s.
|
||||
|
||||
#### `api/bridge.py` — nuovi metodi
|
||||
|
||||
- `spotify_search(query: str, artist_mode: bool) → dict` — salva query+toggle in config, poi chiama la funzione giusta e ritorna `{ok, tracks}` o `{ok:false, error, message}`
|
||||
- `spotify_search_download(tracks: list) → dict` — wrapper del pattern Beatport: converte in `[{name, artist}]` e chiama `start_tracks_download` con `subfolder="Spotify"`
|
||||
- `youtube_search(query: str) → dict` — salva query in config, chiama `core.youtube_search`, ritorna `{ok, tracks}`
|
||||
- `youtube_search_download(tracks: list) → dict` — chiama nuovo helper `start_urls_download` (see below) con URL YouTube e `subfolder="YouTube"`
|
||||
|
||||
#### `api/bridge.py` — nuovo helper (se non c'è già)
|
||||
|
||||
- `start_urls_download(payload: dict) → dict` — analogo a `start_tracks_download` ma accetta `{urls: [str], output_dir, subfolder}` e chiama `download_playlist_from_urls(urls, ...)` in `core/downloader.py`. Se non esiste una funzione equivalente in `downloader.py`, va aggiunta.
|
||||
|
||||
**Verifica in implementazione:** controlla se `download_playlist(tracks, ...)` può ricevere tracks contenenti solo `url` (bypass search); in tal caso riusa quella. Altrimenti aggiungi il nuovo path.
|
||||
|
||||
### Frontend
|
||||
|
||||
- 2 nuove tab in sidebar (`data-view="spotify"` e `data-view="youtube"`), entrambe con `data-feature="audio"` (stesso license gate del Beatport)
|
||||
- 2 nuove section `<section id="view-spotify">` e `<section id="view-youtube">` in `webui/index.html`
|
||||
- Modulo JS `SpotifyUI` e `YoutubeUI` in `webui/js/app.js`, entrambi con pattern init/loadResults/renderTable/updateSelection/startDownload (parallelo a `BeatportUI`)
|
||||
- Stili in `webui/css/style.css` — riusa `.beatport-table` come base, aggiunge varianti dove serve
|
||||
|
||||
### Persistenza
|
||||
|
||||
3 nuovi campi in `core/config.py::DEFAULTS`:
|
||||
- `spotify_search_last_query: str = ""`
|
||||
- `spotify_search_artist_mode: bool = False`
|
||||
- `youtube_search_last_query: str = ""`
|
||||
|
||||
Salvati dal backend a ogni ricerca (analogo a `beatport_last_genre`).
|
||||
|
||||
### Cartelle output
|
||||
|
||||
- `{output_dir}/Spotify/` (piatta, nessun sub-folder per query)
|
||||
- `{output_dir}/YouTube/` (piatta)
|
||||
|
||||
Sanitizzazione slash coerente con il pattern di `start_tracks_download` (subfolder singolo, no nested).
|
||||
|
||||
## Data flow
|
||||
|
||||
### Spotify search
|
||||
|
||||
```
|
||||
[JS] User digita query, opzionale toggle "Solo artista", click Cerca
|
||||
↓
|
||||
[JS] api.spotify_search(query, artist_mode)
|
||||
↓
|
||||
[PY] api/bridge.py::spotify_search():
|
||||
├─ Salva {spotify_search_last_query, spotify_search_artist_mode} in config
|
||||
├─ Verifica creds Spotify (client_id, client_secret) → altrimenti {ok:false, error:"no_creds"}
|
||||
├─ Get token (spotify_client.get_access_token)
|
||||
├─ Se artist_mode:
|
||||
│ └─ spotify_client.search_artist_discography(token, query)
|
||||
├─ Altrimenti:
|
||||
│ └─ spotify_client.search_tracks(token, query, limit=50)
|
||||
└─ Ritorna {ok:True, tracks: [...]}
|
||||
↓
|
||||
[JS] Riceve lista, chiama api.spotify_check_existing() per pre-deselezionare i già-scaricati
|
||||
↓
|
||||
[JS] Renderizza tabella (colonne: check, #, artista, titolo, album, durata, stato)
|
||||
↓
|
||||
[JS] User seleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.spotify_search_download(tracks_selected)
|
||||
↓
|
||||
[PY] converte in [{name, artist}] e chiama self.start_tracks_download({..., subfolder: "Spotify"})
|
||||
↓
|
||||
[EXISTING] pipeline yt-dlp, log/progress su canale "download"
|
||||
```
|
||||
|
||||
### YouTube search
|
||||
|
||||
```
|
||||
[JS] User digita query, click Cerca
|
||||
↓
|
||||
[JS] api.youtube_search(query)
|
||||
↓
|
||||
[PY] api/bridge.py::youtube_search():
|
||||
├─ Salva youtube_search_last_query in config
|
||||
├─ core.youtube_search.search_youtube(query, limit=50)
|
||||
│ └─ subprocess: yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"
|
||||
└─ Ritorna {ok:True, tracks: [{id, url, title, channel, duration_sec}, ...]}
|
||||
↓
|
||||
[JS] Renderizza tabella (colonne: check, #, titolo video, canale, durata, stato)
|
||||
↓
|
||||
[JS] User seleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.youtube_search_download(tracks_selected) # passa URL, non {name,artist}
|
||||
↓
|
||||
[PY] start_urls_download({urls, subfolder: "YouTube"})
|
||||
↓
|
||||
[EXISTING] pipeline yt-dlp diretto sugli URL
|
||||
```
|
||||
|
||||
## Matrice errori & recovery
|
||||
|
||||
| Errore | Dove | Comportamento |
|
||||
|---|---|---|
|
||||
| Query vuota | UI JS | Bottone "Cerca" disabilitato, nessuna richiesta |
|
||||
| Creds Spotify mancanti | spotify_search | `{ok:false, error:"no_creds"}` → banner giallo con link Impostazioni |
|
||||
| Spotify 401 (token scaduto/invalid) | get_access_token | Refresh token nel handler; se persiste, banner rosso |
|
||||
| Spotify 429 rate limit | search endpoint | `{ok:false, error:"rate_limit", message:"Attendi qualche secondo"}` → banner arancione, retry manuale |
|
||||
| Spotify 5xx | search endpoint | `{ok:false, error:"server", message:...}` → banner rosso, retry |
|
||||
| Nessun risultato Spotify | search endpoint | `{ok:true, tracks:[]}` → messaggio grigio "Nessun brano trovato per '<query>'" |
|
||||
| Artist-mode senza match esatto | search_artist_discography | Solleva ValueError → `{ok:false, error:"artist_not_found", message:"Artista '<q>' non trovato — disattiva toggle per ricerca libera"}` |
|
||||
| yt-dlp non trovato | search_youtube | RuntimeError → banner rosso "yt-dlp non installato" |
|
||||
| yt-dlp timeout (rete lenta) | search_youtube | subprocess.TimeoutExpired → banner rosso "Timeout ricerca YouTube" |
|
||||
| yt-dlp errore generico | search_youtube | Non-zero exit → banner rosso con stderr tail |
|
||||
| Nessun risultato YouTube | search_youtube | `{ok:true, tracks:[]}` → messaggio grigio |
|
||||
| User preme Stop durante download | download pipeline | Comportamento invariato (esistente) |
|
||||
|
||||
## Licenza / piani
|
||||
|
||||
Entrambe le tab sono audio download → gate `_gate("audio")` automatico via `start_tracks_download` / `start_urls_download`. Il `daily_limit` del piano si applica. Nessuna nuova feature-flag.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests
|
||||
|
||||
**`tests/test_spotify_client.py` (estensione):**
|
||||
- `test_search_tracks_returns_list_up_to_limit` — mock response con 50 items, verifica list length
|
||||
- `test_search_tracks_maps_fields_correctly` — sample dict → verifica keys {id, url, name, artists, album, duration_sec}
|
||||
- `test_search_tracks_empty_query_returns_empty` — mock 0 items
|
||||
- `test_search_artist_discography_exact_match_wins` — mock search artist con 3 candidati diversi, verifica quello esatto (case-insensitive)
|
||||
- `test_search_artist_discography_top_tracks_and_albums_combined` — mock 3 endpoint, verifica deduplica
|
||||
- `test_search_artist_discography_raises_when_no_match` — mock search artist con 0 risultati → ValueError
|
||||
- `test_search_artist_discography_deduplicates` — mock top-tracks e album-tracks con overlap → verifica no duplicati
|
||||
|
||||
**`tests/test_youtube_search.py` (nuovo):**
|
||||
- `test_search_youtube_parses_entries` — mock `subprocess.run` con JSON stub (5 entries), verifica list mapping
|
||||
- `test_search_youtube_empty_result` — mock JSON senza entries → []
|
||||
- `test_search_youtube_ytdlp_not_found` — mock `find_ytdlp` che ritorna None → RuntimeError
|
||||
- `test_search_youtube_timeout` — mock subprocess.TimeoutExpired → RuntimeError
|
||||
|
||||
### Test manuale end-to-end
|
||||
|
||||
**Tab Spotify:**
|
||||
1. Query semplice: "Solomun" senza toggle → 50 risultati, ordinati per rilevanza
|
||||
2. Query composta: "Kapuchon Hot Sauce" → 5-10 risultati pertinenti
|
||||
3. Artist mode: "Solomun" con toggle attivo → 100-300 risultati (top tracks + tutti album)
|
||||
4. Artist mode con nome inesistente: "sadgjhkasdg" → banner "Artista non trovato"
|
||||
5. Download di 3 brani → file in `MUSICA/Spotify/`
|
||||
6. Ricerca ripetuta → "già scaricato" mostrato correttamente
|
||||
7. Riavvio app → ultima query + toggle ricordati
|
||||
|
||||
**Tab YouTube:**
|
||||
1. Query: "Kapuchon Hot Sauce" → 50 risultati con canali diversi
|
||||
2. Query set DJ: "Solomun Cocoricò 2024" → set lunghi (60+ min) in lista
|
||||
3. Download di 2 brani → file in `MUSICA/YouTube/`
|
||||
4. Query vuota → bottone disabilitato
|
||||
5. Riavvio → ultima query ricordata
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Branch `feat/music-search`
|
||||
2. Bump `core/config.py::VERSION` → `v1.8.1`
|
||||
3. Note release `/tmp/notes-v1.8.1.md`:
|
||||
> **Nuove tab Spotify e YouTube 🔎** — cerca brani per titolo o artista su Spotify (con toggle "solo artista" per scaricare tutta la discografia), oppure cerca su YouTube per trovare mix DJ, unreleased, bootleg e brani non presenti su Spotify.
|
||||
4. Commit + tag `v1.8.1` → CI + release notes background task
|
||||
5. Update DB `releases` tabella sul server (macos + windows rows)
|
||||
|
||||
Zero cambi server-side backend, zero migration.
|
||||
|
||||
## Struttura file impattati
|
||||
|
||||
**Nuovi:**
|
||||
- `core/youtube_search.py`
|
||||
- `tests/test_youtube_search.py`
|
||||
|
||||
**Modificati:**
|
||||
- `core/spotify_client.py` — 2 nuove funzioni pubbliche (`search_tracks`, `search_artist_discography`)
|
||||
- `core/downloader.py` — se serve, aggiunta `download_playlist_from_urls()` per il flusso YouTube
|
||||
- `core/config.py` — VERSION bump + 3 nuovi campi in DEFAULTS
|
||||
- `api/bridge.py` — 6 nuovi metodi Api: `spotify_search`, `spotify_check_existing`, `spotify_search_download`, `youtube_search`, `youtube_check_existing`, `youtube_search_download` + eventuale helper `start_urls_download`
|
||||
- `tests/test_spotify_client.py` — 7 nuovi test
|
||||
- `webui/index.html` — 2 nav-item + 2 sezioni view
|
||||
- `webui/js/app.js` — moduli `SpotifyUI` e `YoutubeUI` (~200 righe cadauno, parallelo a `BeatportUI`)
|
||||
- `webui/css/style.css` — piccole estensioni (o riuso classi Beatport)
|
||||
- `requirements.txt` — nessuna nuova dep
|
||||
|
||||
## Riduzione della duplicazione
|
||||
|
||||
Se durante l'implementazione emerge che i 3 pannelli (Beatport, Spotify, YouTube) hanno logica JS quasi identica (renderTable con checkbox, updateSelectionCount, startDownload wrapper), **valuta** l'estrazione di un `SelectableTracksTable` component in `webui/js/app.js`. Se l'astrazione è chiara e riduce codice significativamente, fallo. Se costringe a hooks/callbacks tortuosi per gestire differenze di colonne, lascia stare — 3 istanze non sono tante e YAGNI.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -91,6 +91,13 @@ a { color: inherit; text-decoration: none; }
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Quando il logo e' un'immagine, niente gradient ne' radius forzato */
|
||||
.brand-logo-img {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
|
||||
+35
-2
@@ -91,6 +91,30 @@
|
||||
.dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; }
|
||||
.dl-support a { color: var(--accent); }
|
||||
|
||||
.dl-code {
|
||||
display: block;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin: 8px 0 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
user-select: all;
|
||||
word-break: break-all;
|
||||
}
|
||||
.dl-sub {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.dl-sub li { margin-bottom: 4px; }
|
||||
.dl-sub strong { color: var(--text); }
|
||||
.dl-sub code { background: var(--bg-card-2); padding: 1px 6px; border-radius: 4px; font-size: 12.5px; }
|
||||
|
||||
.dl-status {
|
||||
max-width: 460px;
|
||||
margin: -16px auto 24px;
|
||||
@@ -177,8 +201,17 @@
|
||||
<ol>
|
||||
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
|
||||
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
|
||||
<li>Doppio click su <strong>Fix Gatekeeper.command</strong> (dentro il DMG): autorizza l'app in 1 click. Si chiude da sola appena finito.</li>
|
||||
<li>Apri MusicTools dalle Applicazioni, inserisci email e chiave per attivare</li>
|
||||
<li>
|
||||
Autorizza l'app (una sola volta, perche' non e' firmata con Developer ID Apple).<br>
|
||||
<strong>Modo piu' semplice:</strong> apri il <strong>Terminale</strong> (cmd+spazio → "Terminale"), incolla questa riga e premi Invio:
|
||||
<p class="dl-code">xattr -dr com.apple.quarantine /Applications/MusicTools.app</p>
|
||||
<strong>In alternativa</strong> (senza Terminale):
|
||||
<ul class="dl-sub">
|
||||
<li><strong>Tasto destro</strong> su <code>Fix Gatekeeper.command</code> nel DMG → <strong>Apri</strong> → <strong>Apri</strong>. Se compare il blocco "non e' in grado di verificare", clicca <strong>Annulla</strong> e procedi col passo successivo.</li>
|
||||
<li>Apri <strong>Impostazioni di Sistema</strong> → <strong>Privacy e sicurezza</strong>. In fondo vedi "Fix Gatekeeper.command e' stato bloccato": clicca <strong>Apri comunque</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Apri <strong>MusicTools</strong> dalle Applicazioni, inserisci email e chiave per attivare</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
|
||||
+52
-158
@@ -4,14 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MusicTools — Scarica, modifica e registra la tua musica</title>
|
||||
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag. Piani da 2,99 €/mese." />
|
||||
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag." />
|
||||
|
||||
<meta property="og:title" content="MusicTools — La toolbox della tua musica" />
|
||||
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows. Piani da 2,99 €/mese o 49,90 €/anno una tantum." />
|
||||
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://musictools.djluza.com" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="assets/favicon-192.png" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,17 +22,16 @@
|
||||
<header class="nav">
|
||||
<div class="container nav-row">
|
||||
<a href="#top" class="brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||
<span class="brand-name">MusicTools</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">Funzioni</a>
|
||||
<a href="#how">Come funziona</a>
|
||||
<a href="#pricing">Piani</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="/download">Download</a>
|
||||
</nav>
|
||||
<a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</a>
|
||||
<a href="mailto:info@djluza.com" class="btn btn-ghost btn-sm">Contatti</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -38,7 +39,7 @@
|
||||
<section class="hero" id="top">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<span class="eyebrow">Per Mac e Windows · Da 2,99 €/mese</span>
|
||||
<span class="eyebrow">Per Mac e Windows</span>
|
||||
<h1>La tua musica, ovunque la trovi.<br/><span class="accent">In un'unica app.</span></h1>
|
||||
<p class="lead">
|
||||
Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K.
|
||||
@@ -46,13 +47,13 @@
|
||||
Modifica i metadati e le copertine. Tutto offline, senza account.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
|
||||
<a href="#features" class="btn btn-ghost btn-lg">Scopri di più</a>
|
||||
<a href="#features" class="btn btn-primary btn-lg">Scopri le funzioni</a>
|
||||
<a href="/download" class="btn btn-ghost btn-lg">Ho già una licenza</a>
|
||||
</div>
|
||||
<ul class="hero-bullets">
|
||||
<li>✓ Mensili da 2,99 € o annuale 49,90 € una tantum</li>
|
||||
<li>✓ Funziona offline · Su 3 dispositivi (Mac + Windows)</li>
|
||||
<li>✓ Aggiornamenti gratuiti inclusi</li>
|
||||
<li>✓ Funziona offline · Su 3 dispositivi</li>
|
||||
<li>✓ Scarica brani, video, registra l'audio di sistema, modifica i tag</li>
|
||||
</ul>
|
||||
|
||||
<p class="hero-customer">
|
||||
@@ -155,8 +156,8 @@
|
||||
<div class="how-grid">
|
||||
<div class="how-step">
|
||||
<div class="how-num">1</div>
|
||||
<h3>Compra la licenza</h3>
|
||||
<p>Checkout sicuro tramite Lemon Squeezy. Ricevi la chiave via email entro pochi secondi.</p>
|
||||
<h3>Ottieni la licenza</h3>
|
||||
<p>Ricevi la chiave via email. L'app non è ancora in vendita: scrivici per essere informato.</p>
|
||||
</div>
|
||||
<div class="how-step">
|
||||
<div class="how-num">2</div>
|
||||
@@ -175,127 +176,34 @@
|
||||
<!-- ============== PRICING ============== -->
|
||||
<section class="section pricing" id="pricing">
|
||||
<div class="container">
|
||||
<span class="eyebrow center">PIANI</span>
|
||||
<h2 class="section-title">Scegli il piano giusto per te</h2>
|
||||
<p class="section-lead">Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.</p>
|
||||
|
||||
<div class="price-grid">
|
||||
|
||||
<!-- BASIC -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-name">Basic</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">2,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Download brani</strong> da Spotify e YouTube</li>
|
||||
<li>✓ <strong>Fino a 10 al giorno</strong></li>
|
||||
<li>✓ MP3 fino a 320kbps</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
<li>✗ Download video</li>
|
||||
<li>✗ Registrazione, metadati, upgrade libreria</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1"
|
||||
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
|
||||
Inizia con Basic
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- PRO -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-badge">Più scelto</div>
|
||||
<div class="price-name">Pro</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">5,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutto di Basic</strong></li>
|
||||
<li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
|
||||
<li>✓ <strong>Fino a 30 al giorno</strong></li>
|
||||
<li>✓ Registrazione audio (BlackHole / loopback)</li>
|
||||
<li>✓ Editor metadati + copertine</li>
|
||||
<li>✓ Upgrade qualità libreria</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/89cb5ab5-9d39-4828-8c44-b67a651aeb46?embed=1"
|
||||
class="btn btn-primary btn-lg btn-block lemonsqueezy-button">
|
||||
Scegli Pro
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- PREMIUM -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-name">Premium</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">9,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutto di Pro</strong></li>
|
||||
<li>✓ <strong>Fino a 150 al giorno</strong></li>
|
||||
<li>✓ Priorità nel supporto via email</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/5e027bbf-30ba-4ef3-bb23-5e7ea4aa7e7e?embed=1"
|
||||
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
|
||||
Passa a Premium
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ANNUAL ONE-TIME -->
|
||||
<div class="price-card price-card-sm price-card-featured">
|
||||
<div class="price-badge price-badge-gold">Miglior valore</div>
|
||||
<div class="price-name">Annual</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">49,90</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/anno</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutte le funzioni sbloccate</strong></li>
|
||||
<li>✓ <strong>Nessun limite giornaliero</strong></li>
|
||||
<li>✓ Valido <strong>12 mesi</strong> dall'acquisto</li>
|
||||
<li>✓ Una tantum, niente rinnovo automatico</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/85e91098-a93c-4008-a155-954b3456a299?embed=1"
|
||||
class="btn btn-primary btn-lg btn-block lemonsqueezy-button"
|
||||
id="buyBtn">
|
||||
Acquista — 49,90 €
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<span class="eyebrow center">DISPONIBILITÀ</span>
|
||||
<h2 class="section-title">In arrivo</h2>
|
||||
<p class="section-lead">Stiamo definendo i piani commerciali. L'app non è ancora in vendita.</p>
|
||||
|
||||
<div class="price-shared">
|
||||
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy</p>
|
||||
<p class="price-consent">
|
||||
Procedendo con l'acquisto dichiari di aver letto e di accettare i
|
||||
<a href="/legal/terms.html">Termini di servizio</a>,
|
||||
l'<a href="/legal/privacy.html">Informativa privacy</a> e la
|
||||
<a href="/legal/refund.html">Politica rimborsi</a>.
|
||||
</p>
|
||||
<p class="price-already">
|
||||
Hai già una licenza? <a href="/download">Scarica l'app →</a>
|
||||
</p>
|
||||
<p class="price-note">
|
||||
Per restare aggiornato sulla disponibilità scrivici a
|
||||
<a href="mailto:info@djluza.com">info@djluza.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="spotify-note">
|
||||
<h3>Per sfruttare al massimo MusicTools serve un account Spotify</h3>
|
||||
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
|
||||
<p>
|
||||
L'app raggiunge il <strong>100% delle sue funzioni</strong> quando colleghi
|
||||
le tue <strong>chiavi API Spotify</strong>. Sono <strong>gratuite</strong>:
|
||||
basta un account Spotify (anche <strong>quello gratis</strong>, niente
|
||||
Premium richiesto) e bastano <strong>2 minuti</strong> per generarle dalla
|
||||
Developer Dashboard. La guida passo-passo e' integrata nell'app.
|
||||
MusicTools funziona <strong>al 100% subito</strong> con YouTube,
|
||||
SoundCloud, TikTok, Instagram, Facebook e i singoli brani Spotify
|
||||
(anche con account Spotify gratuito).<br>
|
||||
Per scaricare <strong>playlist e album interi da Spotify</strong> servono
|
||||
le tue <strong>chiavi API Spotify</strong>, che oggi Spotify rilascia solo
|
||||
a chi ha un <strong>account Premium</strong> attivo (le crei tu dal
|
||||
Developer Dashboard, in 2 minuti, con la guida integrata nell'app).
|
||||
</p>
|
||||
<div class="spotify-note-grid">
|
||||
<div class="sn-col sn-with">
|
||||
<div class="sn-title">✓ Con chiavi API Spotify (consigliato)</div>
|
||||
<div class="sn-title">✓ Con Spotify Premium + chiavi API</div>
|
||||
<ul>
|
||||
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
|
||||
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
|
||||
@@ -303,9 +211,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="sn-col sn-without">
|
||||
<div class="sn-title">Senza chiavi (modalita' base)</div>
|
||||
<div class="sn-title">Senza Premium (modalita' base)</div>
|
||||
<ul>
|
||||
<li>Da Spotify scarichi <strong>un brano alla volta</strong></li>
|
||||
<li>Da Spotify scarichi <strong>un brano alla volta</strong> incollando il link</li>
|
||||
<li>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
|
||||
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
|
||||
</ul>
|
||||
@@ -352,32 +260,24 @@
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Su quanti dispositivi posso installarla?</summary>
|
||||
<p>Tutti i piani coprono fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Come funziona il limite giornaliero?</summary>
|
||||
<p>Sui piani mensili (Basic, Pro, Premium) c'è un limite di download al giorno (10, 30 o 150 a seconda del piano). Il contatore si resetta a <strong>mezzanotte ora di Roma</strong>. Sull'annuale (49,90 €) non c'è nessun limite per 12 mesi.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Posso passare a un piano superiore?</summary>
|
||||
<p>Sì, in qualsiasi momento. Su Lemon Squeezy puoi cambiare piano dalla tua dashboard; la licenza si aggiorna in automatico al successivo controllo dell'app.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Cosa succede quando scade l'abbonamento annuale?</summary>
|
||||
<p>Il piano Annual è una-tantum: vale 12 mesi dall'acquisto, non si rinnova automaticamente. Allo scadere ricomprilo (o scegli un mensile) e tutto torna a funzionare.</p>
|
||||
<p>La licenza copre fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Come funzionano gli aggiornamenti?</summary>
|
||||
<p>Sono <strong>inclusi in tutti i piani</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
||||
<p>Sono <strong>inclusi</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Devo creare un account Spotify Premium o YouTube Premium?</summary>
|
||||
<p>No. Per Spotify ti basta un account gratuito (serve solo per ottenere le credenziali API dal loro dashboard, è gratis). YouTube non richiede account.</p>
|
||||
<summary>Mi serve un abbonamento Spotify Premium?</summary>
|
||||
<p>
|
||||
Solo se vuoi scaricare <strong>playlist e album interi</strong> da Spotify:
|
||||
in quel caso servono le chiavi API Spotify, che oggi Spotify rilascia
|
||||
solo agli account Premium. Con un account Spotify gratuito (o senza
|
||||
account Spotify) <strong>puoi comunque scaricare</strong> da YouTube,
|
||||
SoundCloud, TikTok, IG, FB e i singoli brani Spotify incollando il link.
|
||||
</p>
|
||||
<p>YouTube non richiede alcun account.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
@@ -387,12 +287,11 @@
|
||||
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Da Spotify</strong>: senza chiavi puoi scaricare <strong>un
|
||||
brano alla volta</strong> incollando il link al singolo brano (l'app
|
||||
estrae artista e titolo dai metadati pubblici di Spotify e cerca su
|
||||
YouTube). Per <strong>album e playlist</strong> servono le chiavi API
|
||||
— sono gratis (anche con account Spotify free) e la procedura guidata
|
||||
nell'app ci mette 2 minuti.
|
||||
<strong>Da Spotify</strong>: puoi scaricare <strong>un brano alla volta</strong>
|
||||
incollando il link al singolo brano (l'app estrae artista e titolo
|
||||
dai metadati pubblici di Spotify e cerca su YouTube). Per <strong>album
|
||||
e playlist</strong> servono le chiavi API, che Spotify rilascia solo
|
||||
agli account Premium.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -431,10 +330,6 @@
|
||||
<p>Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> con prova della titolarità, identificazione del materiale e dichiarazione di buona fede. La procedura completa è descritta al <a href="/legal/terms.html#s8">§8 dei Termini</a>.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Quali metodi di pagamento sono accettati?</summary>
|
||||
<p>Tramite Lemon Squeezy: carta di credito/debito, PayPal, Apple Pay, Google Pay. Riceverai una fattura per il pagamento.</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -442,9 +337,9 @@
|
||||
<!-- ============== FINAL CTA ============== -->
|
||||
<section class="section cta-final">
|
||||
<div class="container cta-box">
|
||||
<h2>Pronto a semplificarti la vita?</h2>
|
||||
<p>Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.</p>
|
||||
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
|
||||
<h2>Resta in contatto</h2>
|
||||
<p>Scrivici per qualsiasi domanda o per essere informato sulla disponibilità.</p>
|
||||
<a href="mailto:info@djluza.com" class="btn btn-primary btn-lg">Scrivici a info@djluza.com</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -452,7 +347,7 @@
|
||||
<footer class="footer">
|
||||
<div class="container footer-row">
|
||||
<div class="footer-brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||
<span>MusicTools</span>
|
||||
<span class="footer-by">by LuZa</span>
|
||||
</div>
|
||||
@@ -469,7 +364,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
|
||||
<script src="js/main.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Informativa Privacy</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||
@@ -30,24 +30,26 @@
|
||||
<ul>
|
||||
<li><strong>Email</strong>: associata all'acquisto. Usata per inviare la chiave di licenza, comunicazioni di servizio e supporto.</li>
|
||||
<li><strong>Chiave di licenza, device_id, nome dispositivo, versione app</strong>: salvati nel database licenze per gestire le attivazioni (max 3 dispositivi) e impedire abusi.</li>
|
||||
<li><strong>Piano sottoscritto, id ordine e id sottoscrizione Lemon Squeezy, data scadenza/rinnovo</strong>: necessari per applicare correttamente le funzioni del piano (Basic/Pro/Premium/Annual) e gestire rinnovi, disdette, rimborsi.</li>
|
||||
<li><strong>Contatore download giornaliero</strong>: numero aggregato di download effettuati ogni giorno (azzerato a mezzanotte ora di Roma). Serve esclusivamente ad applicare i limiti del piano (10/30/150 al giorno). Non vengono registrati URL ne' titoli dei brani scaricati.</li>
|
||||
<li><strong>Dati di pagamento</strong>: gestiti esclusivamente da Lemon Squeezy Inc. L'Editore non vede ne' conserva i dati della carta.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Finalita' e base giuridica</h2>
|
||||
<ul>
|
||||
<li>Esecuzione del contratto (consegna della licenza, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||
<li>Esecuzione del contratto (consegna della licenza, gestione del piano sottoscritto, rinnovi, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
|
||||
<li>Sicurezza del software (verifica anti-abuso delle attivazioni) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||
<li>Sicurezza del software e applicazione dei limiti del piano (verifica anti-abuso delle attivazioni, contatore giornaliero) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Conservazione</h2>
|
||||
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni.</p>
|
||||
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni. Il contatore download giornaliero viene mantenuto per 90 giorni e poi cancellato.</p>
|
||||
|
||||
<h2>5. Terze parti</h2>
|
||||
<ul>
|
||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento.</li>
|
||||
<li><strong>Cloudflare Inc.</strong> — hosting infrastruttura backend (Workers, D1, R2, Pages).</li>
|
||||
<li><strong>Resend Inc.</strong> — invio email transazionali.</li>
|
||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento, gestione abbonamenti.</li>
|
||||
<li><strong>Resend Inc.</strong> — invio email transazionali (consegna chiave di licenza, comunicazioni di servizio).</li>
|
||||
<li><strong>Provider di hosting self-managed</strong> — i dati di licenza e il backend API sono ospitati su server gestiti dall'Editore. I dati non vengono condivisi con altri fornitori cloud.</li>
|
||||
</ul>
|
||||
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
|
||||
|
||||
|
||||
@@ -21,20 +21,31 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Politica rimborsi</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<h2>Garanzia 14 giorni</h2>
|
||||
<p>Hai 14 giorni di tempo dall'acquisto per chiedere il rimborso completo, senza fornire motivazioni. La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||
<h2>Garanzia 14 giorni sul primo acquisto</h2>
|
||||
<p>Hai <strong>14 giorni</strong> di tempo dal <strong>primo acquisto</strong> per chiedere il rimborso completo, senza fornire motivazioni. Vale per tutti i piani: Basic, Pro, Premium e Annual.</p>
|
||||
<p>La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||
|
||||
<h2>Cosa succede dopo</h2>
|
||||
<h2>Differenze per piano</h2>
|
||||
<ul>
|
||||
<li><strong>Basic / Pro / Premium</strong> (mensili): puoi disdire l'abbonamento <strong>in qualsiasi momento</strong> dalla dashboard di Lemon Squeezy o scrivendoci. La disdetta interrompe il rinnovo successivo; la licenza resta attiva fino alla scadenza del periodo gia pagato. Il rimborso integrale e' previsto solo entro 14 giorni dal primo addebito. <strong>I rinnovi successivi al primo non sono rimborsabili</strong> ma sono interrompibili con disdetta.</li>
|
||||
<li><strong>Annual</strong> (una tantum, 49,90 € per 12 mesi): rimborso integrale entro 14 giorni dall'acquisto. Oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Cosa succede dopo la richiesta</h2>
|
||||
<ol>
|
||||
<li>Riceverai una risposta entro 48 ore lavorative.</li>
|
||||
<li>Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento usato per l'acquisto. Tempi tipici: 5-10 giorni lavorativi.</li>
|
||||
<li>La tua chiave di licenza viene revocata. L'app smette di funzionare al successivo controllo online.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Come disdire un abbonamento mensile</h2>
|
||||
<p>Se sei oltre i 14 giorni e vuoi solo non rinnovare, accedi alla tua dashboard Lemon Squeezy (link nella ricevuta) e clicca "Cancel subscription". In alternativa scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a> e la disattiviamo per te.</p>
|
||||
|
||||
<h2>Limitazioni</h2>
|
||||
<p>Trascorsi 14 giorni dall'acquisto non e' piu' possibile chiedere il rimborso, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||
<p>Trascorsi 14 giorni dal primo acquisto non e' piu' possibile chiedere il rimborso integrale, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||
<p>Spotify potrebbe modificare in qualsiasi momento le proprie API o le condizioni di accesso (es. richiedere un account Premium per generare le credenziali). Tali modifiche di terze parti non costituiscono titolo per il rimborso del piano MusicTools acquistato. La sezione "Esclusione di garanzie" dei <a href="/legal/terms.html">Termini di servizio</a> si applica integralmente.</p>
|
||||
|
||||
<h2>Domande?</h2>
|
||||
<p>Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>: siamo persone vere, rispondiamo entro un giorno lavorativo.</p>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Termini di servizio e Licenza d'uso</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<div class="warn-box">
|
||||
<p><strong>Leggi attentamente.</strong> Acquistando, installando o usando MusicTools accetti i presenti Termini. Se non li accetti, non installare ne' usare il software.</p>
|
||||
@@ -163,6 +163,7 @@
|
||||
<li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li>
|
||||
</ul>
|
||||
<p>Le piattaforme di origine (Spotify, YouTube, ecc.) possono modificare in qualsiasi momento le proprie API, formati o politiche, rendendo il Software temporaneamente o permanentemente inutilizzabile per certe funzioni. L'Editore non garantisce alcuna continuita' funzionale.</p>
|
||||
<p><strong>Credenziali Spotify Web API:</strong> per scaricare playlist e album interi da Spotify il Software richiede che l'Utente generi proprie chiavi API (Client ID e Client Secret) dalla Spotify Developer Dashboard e le configuri all'interno del Software. Spotify, in seguito ad aggiornamenti della propria policy, consente la creazione di tali credenziali esclusivamente agli utenti con <strong>account Spotify Premium</strong> attivo. L'Editore non e' in alcun modo affiliato a Spotify, non fornisce, vende o rivende account Spotify, non puo' garantire l'accessibilita' o il funzionamento delle API Spotify per gli account Free, e non e' responsabile per limitazioni, sospensioni o ban applicati da Spotify alle credenziali dell'Utente. I download di singoli brani da Spotify, basati su metadati pubblici, non richiedono credenziali API ne' un abbonamento Premium.</p>
|
||||
|
||||
<h2 id="s10">10. Limitazione di responsabilita</h2>
|
||||
<p>Nei limiti massimi consentiti dalla legge applicabile, in nessun caso l'Editore sara' responsabile verso l'Utente o verso terzi per:</p>
|
||||
@@ -185,14 +186,25 @@
|
||||
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="s12">12. Pagamenti e fatturazione</h2>
|
||||
<h2 id="s12">12. Piani, prezzi e fatturazione</h2>
|
||||
<p>I pagamenti sono gestiti da <strong>Lemon Squeezy Inc.</strong>, che opera come Merchant of Record (MoR). Lemon Squeezy emette la ricevuta/fattura fiscale, gestisce l'IVA secondo la giurisdizione del cliente (regime MOSS UE) e fornisce assistenza per problematiche di pagamento.</p>
|
||||
<p>Prezzo pubblicato: <strong>39,90 € una tantum</strong> (IVA inclusa secondo paese di residenza dell'Utente).</p>
|
||||
<p>Il Software e' commercializzato in quattro piani, ciascuno con prezzi indicati IVA inclusa secondo paese di residenza dell'Utente:</p>
|
||||
<ul>
|
||||
<li><strong>Basic</strong> — 2,99 € / mese (abbonamento). Include il download di brani audio fino a 10 al giorno; non include video, registrazione, editor metadati, upgrade libreria.</li>
|
||||
<li><strong>Pro</strong> — 5,99 € / mese (abbonamento). Include audio + video + registrazione + editor metadati + upgrade libreria, fino a 30 download al giorno.</li>
|
||||
<li><strong>Premium</strong> — 9,99 € / mese (abbonamento). Stesse funzioni del Pro con limite di 150 download al giorno.</li>
|
||||
<li><strong>Annual</strong> — 49,90 € una tantum. Tutte le funzioni sbloccate, nessun limite giornaliero, validita' 12 (dodici) mesi dalla data di acquisto. Non si rinnova automaticamente.</li>
|
||||
</ul>
|
||||
<p><strong>Limiti giornalieri:</strong> il contatore si azzera ogni giorno a mezzanotte ora di Roma (Europe/Rome). I download non utilizzati non si cumulano.</p>
|
||||
<p><strong>Abbonamenti mensili (Basic, Pro, Premium):</strong> si rinnovano automaticamente alla data di addebito mensile fino a disdetta dell'Utente, che puo' essere effettuata in qualsiasi momento dalla dashboard Lemon Squeezy o scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a>. La disdetta interrompe il rinnovo successivo; la Licenza resta attiva fino alla scadenza del periodo gia pagato.</p>
|
||||
<p><strong>Piano Annual (una tantum):</strong> il pagamento e' singolo e copre 12 mesi consecutivi. Allo scadere, la Licenza diventa inattiva e per continuare a utilizzare il Software l'Utente puo' acquistare nuovamente un piano a sua scelta.</p>
|
||||
<p>L'Utente puo' passare a un piano superiore (upgrade) o inferiore (downgrade) in qualsiasi momento. Il prorata viene gestito secondo le regole di Lemon Squeezy.</p>
|
||||
|
||||
<h2 id="s13">13. Diritto di recesso e rimborsi</h2>
|
||||
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dall'acquisto, senza fornire motivazioni.</p>
|
||||
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dal primo acquisto, senza fornire motivazioni. Il diritto di recesso si applica al primo addebito; non si applica ai rinnovi successivi di un abbonamento mensile, per i quali resta possibile la disdetta come previsto al §12.</p>
|
||||
<p>La richiesta va inviata a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto. Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento; tempi tipici 5-10 giorni lavorativi.</p>
|
||||
<p>A seguito del rimborso, la Licenza viene revocata e il Software smettera' di funzionare al successivo controllo online.</p>
|
||||
<p>L'Utente non puo' richiedere il rimborso dell'importo gia pagato per i mesi di abbonamento gia consumati. Il piano Annual, in caso di recesso entro 14 giorni, viene rimborsato interamente; oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</p>
|
||||
|
||||
<h2 id="s14">14. Aggiornamenti e supporto</h2>
|
||||
<p>L'Editore si impegna a mantenere il Software funzionante e a rilasciare aggiornamenti gratuiti per tutta la durata commerciale del prodotto. Non e' garantito un calendario specifico di rilascio.</p>
|
||||
|
||||
@@ -5,5 +5,12 @@ mutagen>=1.47.0
|
||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||
curl_cffi>=0.9.0
|
||||
|
||||
# --- dev only ---
|
||||
pytest>=8.0.0
|
||||
responses>=0.25.0
|
||||
freezegun>=1.4.0
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Estrae lo slug + numeric id di tutti i generi Beatport.
|
||||
Uso: python3 scripts/refresh_beatport_genres.py > /tmp/genres.txt
|
||||
Poi copia manualmente in core/beatport.py::GENRES.
|
||||
|
||||
Nota: la pagina /genres (task originale) restituisce 404 sull'attuale sito
|
||||
Beatport. Aggreghiamo quindi la lista dei generi dalla pagina /charts, dove
|
||||
ogni featured chart include un array `genres` completo di slug + id + name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
|
||||
GENRES_URL = "https://www.beatport.com/genres"
|
||||
CHARTS_URL = "https://www.beatport.com/charts"
|
||||
|
||||
|
||||
def _fetch_next_data(url: str) -> dict:
|
||||
resp = requests.get(url, impersonate="chrome131", timeout=15)
|
||||
resp.raise_for_status()
|
||||
m = re.search(
|
||||
r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>', resp.text, re.DOTALL
|
||||
)
|
||||
if not m:
|
||||
raise RuntimeError(f"__NEXT_DATA__ non trovato su {url}")
|
||||
return json.loads(m.group(1))
|
||||
|
||||
|
||||
def _collect_genres(node, out: dict) -> None:
|
||||
"""Raccoglie ricorsivamente ogni dict con chiavi slug/id/name che appare
|
||||
dentro un array chiamato "genres"."""
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if (
|
||||
k == "genres"
|
||||
and isinstance(v, list)
|
||||
and v
|
||||
and all(
|
||||
isinstance(x, dict) and "slug" in x and "id" in x and "name" in x
|
||||
for x in v
|
||||
)
|
||||
):
|
||||
for g in v:
|
||||
out[g["id"]] = (g["slug"], g["name"])
|
||||
else:
|
||||
_collect_genres(v, out)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
_collect_genres(item, out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Prova /genres (URL originale). Se 404 o schema inatteso, cade su /charts.
|
||||
try:
|
||||
data = _fetch_next_data(GENRES_URL)
|
||||
queries = data["props"]["pageProps"]["dehydratedState"]["queries"]
|
||||
for q in queries:
|
||||
d = q.get("state", {}).get("data", {})
|
||||
if (
|
||||
isinstance(d, dict)
|
||||
and "results" in d
|
||||
and isinstance(d["results"], list)
|
||||
):
|
||||
for g in d["results"]:
|
||||
if "slug" in g and "id" in g and "name" in g:
|
||||
print(f' "{g["slug"]}": ({g["id"]}, "{g["name"]}"),')
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"# /genres non disponibile ({e}), fallback su /charts", file=sys.stderr)
|
||||
|
||||
data = _fetch_next_data(CHARTS_URL)
|
||||
genres: dict = {}
|
||||
_collect_genres(data, genres)
|
||||
if not genres:
|
||||
print("nessun genere trovato su /charts", file=sys.stderr)
|
||||
return 1
|
||||
for gid in sorted(genres):
|
||||
slug, name = genres[gid]
|
||||
print(f' "{slug}": ({gid}, "{name}"),')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
// Genera una licenza nel DB delle licenze MusicTools.
|
||||
//
|
||||
// Da eseguire SUL SERVER (musictools@musictools.djluza.com) dentro ~/api/:
|
||||
// node scripts/gen-license.mjs <email> [plan]
|
||||
//
|
||||
// plan: annual (default, "full" — no limite, no scadenza)
|
||||
// basic | pro | premium (con daily_limit e period_end mensile)
|
||||
//
|
||||
// Da locale, usare lo script wrapper: server/scripts/gen-license.sh
|
||||
|
||||
import { generateLicenseKey } from "../src/license.js";
|
||||
import { getPlan } from "../src/plans.js";
|
||||
import mysql from "mysql2/promise";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.resolve(__dirname, "..", ".env");
|
||||
fs.readFileSync(envPath, "utf-8").split("\n").forEach((l) => {
|
||||
const m = l.match(/^([A-Z_]+)=(.*)$/);
|
||||
if (m) process.env[m[1]] = m[2];
|
||||
});
|
||||
|
||||
const [, , emailArg, planArg] = process.argv;
|
||||
if (!emailArg) {
|
||||
console.error("Uso: node scripts/gen-license.mjs <email> [plan]");
|
||||
console.error("plan: annual (default) | basic | pro | premium");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const email = emailArg.trim().toLowerCase();
|
||||
const planCode = (planArg || "annual").trim().toLowerCase();
|
||||
const plan = getPlan(planCode);
|
||||
if (!plan) {
|
||||
console.error(`Piano sconosciuto: ${planCode}`);
|
||||
console.error("Validi: annual, basic, pro, premium");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: "127.0.0.1",
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
const key = generateLicenseKey();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const isAnnual = planCode === "annual";
|
||||
const dailyLimit = plan.dailyLimit ?? null;
|
||||
const expiresAt = isAnnual ? null : null;
|
||||
const periodEnd = isAnnual ? null : now + 30 * 24 * 3600;
|
||||
|
||||
await pool.execute(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, plan, daily_limit, source,
|
||||
subscription_id, current_period_end,
|
||||
expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, 'manual', NULL, ?, ?, ?, ?)`,
|
||||
[key, email, planCode, dailyLimit, periodEnd, expiresAt, now, now],
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
key,
|
||||
email,
|
||||
plan: planCode,
|
||||
daily_limit: dailyLimit,
|
||||
expires_at: expiresAt,
|
||||
current_period_end: periodEnd,
|
||||
}, null, 2));
|
||||
|
||||
await pool.end();
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Genera una licenza MusicTools da locale, eseguendo lo script sul server.
|
||||
#
|
||||
# Uso:
|
||||
# ./server/scripts/gen-license.sh <email> [plan]
|
||||
#
|
||||
# plan: annual (default, "full" — no limite, no scadenza)
|
||||
# basic | pro | premium (con daily_limit e period_end mensile)
|
||||
#
|
||||
# Richiede:
|
||||
# - ssh con chiave configurata verso musictools@musictools.djluza.com
|
||||
# - lo script server-side viene sincronizzato in automatico prima dell'esecuzione
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
EMAIL="${1:-}"
|
||||
PLAN="${2:-annual}"
|
||||
|
||||
if [ -z "$EMAIL" ]; then
|
||||
echo "Uso: $0 <email> [plan]" >&2
|
||||
echo "plan: annual (default) | basic | pro | premium" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SSH_TARGET="musictools@musictools.djluza.com"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
ssh "$SSH_TARGET" 'mkdir -p ~/api/scripts'
|
||||
rsync -a --quiet \
|
||||
"$SCRIPT_DIR/gen-license.mjs" \
|
||||
"${SSH_TARGET}:api/scripts/gen-license.mjs"
|
||||
|
||||
ssh "$SSH_TARGET" "cd ~/api && node scripts/gen-license.mjs '$EMAIL' '$PLAN'"
|
||||
+50
-11
@@ -73,18 +73,31 @@ async function handleOrderCreated(payload, attrs, t) {
|
||||
const planCode = planByVariantId(variantId);
|
||||
const plan = planCode ? getPlan(planCode) : null;
|
||||
|
||||
// Subscriptions: il record vero arriva con subscription_created.
|
||||
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione
|
||||
// non ancora generata), accettiamo l'evento ma non creiamo nulla.
|
||||
if (plan && plan.is_subscription) {
|
||||
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } };
|
||||
// Se l'order e' gia stato processato (lo riconosciamo dall'order_id),
|
||||
// non duplichiamo licenza ne' email — ack 200 e basta.
|
||||
const existing = await one(
|
||||
"SELECT id FROM licenses WHERE order_id=? LIMIT 1",
|
||||
[orderId],
|
||||
);
|
||||
if (existing) {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
// Annual one-time (o piano non mappato -> trattalo come annual di default
|
||||
// cosi' non lasciamo l'utente senza licenza se ha pagato).
|
||||
// Piano non mappato -> fallback annual cosi' non blocchiamo l'utente
|
||||
// che ha gia pagato (la licenza sara' usabile anche se non sappiamo
|
||||
// i limiti esatti del piano comprato).
|
||||
const planForRecord = plan?.code || "annual";
|
||||
const limit = plan?.daily_limit ?? null;
|
||||
const expiresAt = t + ANNUAL_TTL_SECONDS;
|
||||
const planMeta = plan || getPlan("annual");
|
||||
const limit = planMeta?.daily_limit ?? null;
|
||||
|
||||
// Annual one-time: settiamo expires_at = now + 365d.
|
||||
// Subscription mensili: NON settiamo current_period_end qui — lo fara'
|
||||
// subscription_created/subscription_payment_success quando arriva.
|
||||
// Se quell'evento non arrivasse mai (perche' non abilitato in LS
|
||||
// dashboard), la licenza resta utilizzabile (current_period_end NULL
|
||||
// = no scadenza imposta lato server: la sottoscrizione vivra' o
|
||||
// morira' su LS, e i webhook successivi aggiorneranno lo status).
|
||||
const expiresAt = planMeta?.is_subscription ? null : t + ANNUAL_TTL_SECONDS;
|
||||
const key = generateLicenseKey();
|
||||
|
||||
try {
|
||||
@@ -103,7 +116,10 @@ async function handleOrderCreated(payload, attrs, t) {
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLicenseEmail(email, key, { plan: planForRecord, expiresAt });
|
||||
await sendLicenseEmail(email, key, {
|
||||
plan: planForRecord,
|
||||
expiresAt: expiresAt,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
@@ -141,7 +157,7 @@ async function handleSubscriptionCreated(payload, attrs, t) {
|
||||
const key = generateLicenseKey();
|
||||
|
||||
// Idempotenza: se ricevo lo stesso subscription_created due volte,
|
||||
// riuso la licenza esistente (non ne creo un'altra).
|
||||
// riuso la licenza esistente.
|
||||
const existing = await one(
|
||||
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
|
||||
[subId],
|
||||
@@ -156,6 +172,29 @@ async function handleSubscriptionCreated(payload, attrs, t) {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
// Caso normale ora: la licenza e' gia stata creata da order_created
|
||||
// (con subscription_id NULL). La aggiorno con subscription_id e
|
||||
// current_period_end senza generare una nuova chiave ne' una nuova email.
|
||||
const byOrder = orderId
|
||||
? await one(
|
||||
`SELECT id FROM licenses
|
||||
WHERE order_id=? AND subscription_id IS NULL LIMIT 1`,
|
||||
[orderId],
|
||||
)
|
||||
: null;
|
||||
if (byOrder) {
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET subscription_id=?, current_period_end=?, plan=?, daily_limit=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
[subId, periodEnd, planCode, plan.daily_limit, t, byOrder.id],
|
||||
);
|
||||
return { status: 200, body: { ok: true, attached_subscription: subId } };
|
||||
}
|
||||
|
||||
// Fallback: nessuna licenza esistente -> ne creiamo una nuova
|
||||
// (puo' succedere se order_created non e' arrivato per qualche motivo).
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
"""Fixtures pytest condivise."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures_dir() -> Path:
|
||||
return FIXTURES_DIR
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8" data-next-head=""/><meta name="viewport" content="width=device-width" data-next-head=""/><link rel="canonical" hrefLang="en" href="https://www.beatport.com/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="de" href="https://www.beatport.com/de/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="fr" href="https://www.beatport.com/fr/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="es" href="https://www.beatport.com/es/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="it" href="https://www.beatport.com/it/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="ja" href="https://www.beatport.com/ja/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="pt" href="https://www.beatport.com/pt/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="nl" href="https://www.beatport.com/nl/genre/melodic-house-techno/90/top-100" data-next-head=""/><title data-next-head="">Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport</title><meta property="og:title" content="Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport" data-next-head=""/><meta property="og:description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><meta property="og:image" content="https://www.beatport.com/images/beatport-social-image.png" data-next-head=""/><meta name="twitter:card" content="summary_large_image" data-next-head=""/><meta name="twitter:title" content="Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport" data-next-head=""/><meta name="twitter:description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><meta name="twitter:image" content="https://www.beatport.com/images/beatport-social-image.png" data-next-head=""/><meta name="description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><link rel="icon" type="image/x-icon" href="/images/favicon-48x48.png" data-next-head=""/><link href="https://js.recurly.com/v4/recurly.css" rel="stylesheet" type="text/css" data-next-head=""/><meta name="bp-env" content="production"/><script>window.__SENTRY_RELEASE__="BP-v1.162.0"</script><link rel="preload" href="/icons/sprite.svg" as="image"/><link rel="preload" href="/_next/static/media/9617edd9e02a645a-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><link rel="preload" href="/_next/static/media/9438673d1e72232d-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><link rel="preload" href="/_next/static/media/db5c0e5da3a6b3a9-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><script id="google-analytics" data-nscript="beforeInteractive">
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-TXN4GRJ');
|
||||
</script><link rel="preload" href="/_next/static/css/dbc5caec36a91cd9.css" as="style"/><link rel="stylesheet" href="/_next/static/css/dbc5caec36a91cd9.css" data-n-g=""/><noscript data-n-css=""></noscript><script defer="" nomodule="" src="/_next/static/chunks/polyfills-42372ed130431b0a.js"></script><script id="ab-tasty" src="https://try.abtasty.com/6a6f6df6afac8eb063d6dbbd68afdcc7.js" defer="" data-nscript="beforeInteractive"></script><script src="https://songbird.cardinalcommerce.com/edge/v1/songbird.js" defer="" data-nscript="beforeInteractive"></script><script id="pap_x2s6df8d" src="https://pluginboutique.postaffiliatepro.com/scripts/trackjs.js" defer="" data-nscript="beforeInteractive"></script><script src="/_next/static/chunks/webpack-6f1bbaae2c104d2c.js" defer=""></script><script src="/_next/static/chunks/framework-0353c99733a74dff.js" defer=""></script><script src="/_next/static/chunks/main-6be71f34df367310.js" defer=""></script><script src="/_next/static/chunks/pages/_app-481baf8cf9b61739.js" defer=""></script><script src="/_next/static/chunks/1672-274ad8535921289a.js" defer=""></script><script src="/_next/static/chunks/pages/genre/%5Bdescription%5D/%5Bid%5D/top-100-991e44049fcd48b3.js" defer=""></script><script src="/_next/static/z5IFYCzpmVKqxZJshoItQ/_buildManifest.js" defer=""></script><script src="/_next/static/z5IFYCzpmVKqxZJshoItQ/_ssgManifest.js" defer=""></script><style data-styled="" data-styled-version="6.4.3">.dXSwbq{box-sizing:border-box;display:flex;flex-direction:row;justify-content:center;align-items:center;padding:0.313rem 1rem 0.438rem;gap:0.5rem;border-radius:0.25rem;cursor:pointer;font-family:'aeonikFont','aeonikFont Fallback';font-size:0.875rem;line-height:1.25rem;font-weight:500;color:#141414;}/*!sc*/
|
||||
.dXSwbq.white{background-color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.white:hover{background-color:#01CC77;}/*!sc*/
|
||||
.dXSwbq.primary{background:#01FF95;}/*!sc*/
|
||||
.dXSwbq.primary:hover{background:#01CC77;}/*!sc*/
|
||||
.dXSwbq.primary.primary.disabled{pointer-events:none;color:#343434;background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.primary.danger{background-color:#CF222E;border-color:transparent;}/*!sc*/
|
||||
.dXSwbq.primary.danger:hover{background:#99242B;}/*!sc*/
|
||||
.dXSwbq.outline{border:0.063rem solid;border-image-source:linear-gradient( 0deg,rgba(0,0,0,0.6),rgba(0,0,0,0.6) ),linear-gradient( 0deg,#01FF95,#01FF95 );color:#01FF95;}/*!sc*/
|
||||
.dXSwbq.outline:hover{border:0.063rem solid;color:#141414;background:#01FF95;}/*!sc*/
|
||||
.dXSwbq.outline.outline.disabled{background:transparent;color:#01FF95;opacity:50%;}/*!sc*/
|
||||
.dXSwbq.outline-secondary{border:0.063rem solid;border-color:#8C8C8C;color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.outline-secondary:hover{background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.outline.disabled{background:transparent;color:#FFFFFF;opacity:50%;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:disabled{cursor:auto;border-color:#5A5A5A;color:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:disabled svg{fill:#5A5A5A;stroke:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:hover:not(:disabled){background:#CF222E;border-color:transparent;}/*!sc*/
|
||||
.dXSwbq.tertiary{color:#FFFFFF;background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.tertiary:hover{background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.tertiary.tertiary.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.secondary{color:#FFFFFF;background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.secondary:hover{background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.secondary.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.danger{color:#FFFFFF;background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.danger:hover{background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.danger.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.transparent,.dXSwbq.transparent-danger{color:#FFFFFF;border:0.125rem solid #5A5A5A;padding:0.313rem 0.75rem;gap:0.375rem;}/*!sc*/
|
||||
.dXSwbq.transparent:hover{background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.transparent-danger:hover{border:0.125rem solid #CF222E;background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.pill{color:#FFFFFF;background:#343434;border-radius:0.375rem;padding:0.75rem 1rem;height:2.5rem;}/*!sc*/
|
||||
.dXSwbq.pill:hover{background:#494949;}/*!sc*/
|
||||
.dXSwbq.link{color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.primary-link{color:#01FF95;}/*!sc*/
|
||||
.dXSwbq.primary-link.disabled{cursor:default;}/*!sc*/
|
||||
.dXSwbq.text{color:#B3B3B3;}/*!sc*/
|
||||
.dXSwbq.text:hover{color:#FFFFFF;text-decoration:underline;}/*!sc*/
|
||||
.dXSwbq.underline-link{color:#B3B3B3;text-decoration:underline;padding:0.25rem;}/*!sc*/
|
||||
.dXSwbq.unstyled{background:none;border:none;padding:0;margin:0;color:inherit;background-color:transparent;}/*!sc*/
|
||||
.dXSwbq.w-100{width:100%;}/*!sc*/
|
||||
.dXSwbq.w-75{width:75%;}/*!sc*/
|
||||
.dXSwbq.w-50{width:50%;}/*!sc*/
|
||||
.dXSwbq.w-25{width:25%;}/*!sc*/
|
||||
.dXSwbq.squared{padding:0.313rem;}/*!sc*/
|
||||
data-styled.g1[id="Button-style__ButtonStyled-sc-e1b4adaf-0"]{content:"dXSwbq,"}/*!sc*/
|
||||
.jVzvJX{position:relative;margin-left:0.75rem;padding:0.5rem 0;}/*!sc*/
|
||||
.jVzvJX >a,.jVzvJX >button{display:flex;flex-direction:row;gap:0.3125rem;cursor:pointer;align-items:center;padding:0.313rem 0.25rem 0.438rem 0.25rem;color:#FFFFFF;}/*!sc*/
|
||||
.jVzvJX >a svg,.jVzvJX >button svg{stroke:#FFFFFF;}/*!sc*/
|
||||
@media (min-width: 1200px){.jVzvJX >a:hover,.jVzvJX >button:hover{color:#01FF95;}.jVzvJX >a:hover svg,.jVzvJX >button:hover svg{stroke:#01FF95;}}/*!sc*/
|
||||
@media (min-width: 1200px){.jVzvJX >a,.jVzvJX >button{padding:0;}.jVzvJX >a svg,.jVzvJX >button svg{width:1.3rem;height:1.3rem;}}/*!sc*/
|
||||
data-styled.g35[id="HeaderCart-style__AccountCartMenuItem-sc-6f1edafa-2"]{content:"jVzvJX,"}/*!sc*/
|
||||
.dkwGxt{position:absolute;top:3.6875rem;left:50%;transform:translate(-50%,1px);transition:opacity 0.2s ease;background:#141414;border-radius:0 0 1rem 1rem;display:block;z-index:31;cursor:default;opacity:0;visibility:hidden;pointer-events:none;box-shadow:0 0.5rem 1.5rem rgba(0,0,0,0.5);}/*!sc*/
|
||||
.dkwGxt[data-dropdown-type='genre']{width:100%;max-width:90rem;}/*!sc*/
|
||||
.dkwGxt[data-dropdown-type='events']{left:auto;min-width:12.5rem;margin:0 0 0 4.75rem;}/*!sc*/
|
||||
.dkwGxt[data-visible='true']{opacity:1;pointer-events:auto;visibility:visible;}/*!sc*/
|
||||
data-styled.g40[id="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0"]{content:"dkwGxt,"}/*!sc*/
|
||||
.jgwRkR{display:flex;flex-direction:column;padding:0.5rem 0;}/*!sc*/
|
||||
.jgwRkR a{padding:0.5rem 1.5rem;color:#B3B3B3;text-decoration:none;display:flex;align-items:center;transition:background-color 0.2s ease;}/*!sc*/
|
||||
.jgwRkR a:hover{color:#FFFFFF;}/*!sc*/
|
||||
.jgwRkR a:hover >svg{stroke:#01FF95;}/*!sc*/
|
||||
data-styled.g42[id="HeaderEventsSubMenu-style__EventsSubMenuWrapper-sc-75ecafea-0"]{content:"jgwRkR,"}/*!sc*/
|
||||
.kNtdbg{display:grid;grid-template-columns:1fr;flex-direction:row;justify-content:space-between;align-items:flex-start;width:100%;padding:1.5rem;gap:2rem;}/*!sc*/
|
||||
data-styled.g48[id="HeaderGenreSubMenu-style__GenreDropDownGrid-sc-5f01336e-4"]{content:"kNtdbg,"}/*!sc*/
|
||||
.gZFJrf{display:flex;flex-direction:row;position:absolute;top:0;left:0;right:0;z-index:40;height:60px;width:100%;font-size:0.875rem;background:#141414;border-bottom:0.0625rem solid #343434;}/*!sc*/
|
||||
.gZFJrf.header-fixed{position:fixed;}/*!sc*/
|
||||
.gZFJrf ul> li{display:inline-flex;vertical-align:middle;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag){display:flex;flex-direction:row;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag) >img{margin-right:0.25rem;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag) svg{stroke:#8C8C8C;}/*!sc*/
|
||||
@keyframes slideDown{from{transform:translateY(-100%);}to{transform:translateY(0);}}/*!sc*/
|
||||
data-styled.g50[id="Header-style__HeaderWrapper-sc-54c5f6f1-0"]{content:"gZFJrf,"}/*!sc*/
|
||||
.kFaHkN{display:flex;flex-direction:row;width:100%;max-width:90rem;margin:0 auto;align-items:center;}/*!sc*/
|
||||
.kFaHkN svg{stroke:#8C8C8C;}/*!sc*/
|
||||
.kFaHkN >ul .dropdown_menu{animation:growDown 300ms ease-in-out forwards;transform-origin:top center;}/*!sc*/
|
||||
.kFaHkN >ul >li{line-height:1.25rem;padding:1.25rem 0.4375rem;cursor:pointer;}/*!sc*/
|
||||
.kFaHkN >ul >li.header_item:not(.dropdown_toggle):hover div{display:flex;}/*!sc*/
|
||||
.kFaHkN >ul >li.account button{padding:0;}/*!sc*/
|
||||
.kFaHkN >ul >li button{font-weight:400;}/*!sc*/
|
||||
.kFaHkN >ul .header_item [aria-expanded="true"] .rotatable svg{stroke:#01FF95;transform:rotate(180deg);margin-top:0.3125rem;}/*!sc*/
|
||||
.kFaHkN >ul .header_item .svg_wrapper{display:flex;align-items:center;height:100%;}/*!sc*/
|
||||
.kFaHkN >ul .header_item .svg_wrapper img{border-radius:50%;}/*!sc*/
|
||||
.kFaHkN >ul .header_item a[target="_blank"]:hover svg{stroke:#01FF95;}/*!sc*/
|
||||
.kFaHkN span{align-items:center;}/*!sc*/
|
||||
.kFaHkN span >img{margin-left:0.125rem;margin-bottom:0.0625rem;}/*!sc*/
|
||||
@keyframes growDown{0%{transform:scaleY(0);}100%{transform:scaleY(1);}}/*!sc*/
|
||||
data-styled.g52[id="Header-style__DesktopHeader-sc-54c5f6f1-2"]{content:"kFaHkN,"}/*!sc*/
|
||||
.eRPXdM{min-width:8rem;margin-left:1rem;}/*!sc*/
|
||||
.eRPXdM >li{padding:0 0 0 0.25rem;}/*!sc*/
|
||||
.eRPXdM >li button{padding:0.8rem 0.75rem 0.5rem 0.25rem;}/*!sc*/
|
||||
@media (min-width: 768px){.eRPXdM{width:100%;}}/*!sc*/
|
||||
@media (min-width: 1200px){.eRPXdM{min-width:44.5rem;}}/*!sc*/
|
||||
data-styled.g53[id="Header-style__LeftRegion-sc-54c5f6f1-3"]{content:"eRPXdM,"}/*!sc*/
|
||||
.exexVn{display:flex;justify-content:flex-end;align-items:center;width:100%;padding-right:1.25rem;}/*!sc*/
|
||||
.exexVn .svg_wrapper{display:flex;align-items:center;height:100%;}/*!sc*/
|
||||
.exexVn div.account_avatar{width:2rem;height:2rem;}/*!sc*/
|
||||
.exexVn div.account_avatar img{border-radius:50%;}/*!sc*/
|
||||
.exexVn div.account_avatar img{aspect-ratio:1/1;object-fit:cover;object-position:center;}/*!sc*/
|
||||
.exexVn .subscription_button a{padding:0.313rem 1rem 0.438rem;border-radius:0.25rem;font-weight:500;color:#141414;background:#01FF95;}/*!sc*/
|
||||
.exexVn .subscription_button a:hover{background:#01CC77;}/*!sc*/
|
||||
@media (min-width: 768px){.exexVn{min-width:9.5rem;max-width:49.375rem;}}/*!sc*/
|
||||
@media (min-width: 1200px){.exexVn li.search:first-child{width:100%;max-width:28.375rem;padding:0 0.5rem 0 0;}.exexVn li.subscription_button{min-width:9rem;}}/*!sc*/
|
||||
data-styled.g54[id="Header-style__RightRegion-sc-54c5f6f1-4"]{content:"exexVn,"}/*!sc*/
|
||||
.dNxFON{height:1.8125rem;width:1.375rem;margin-right:0.625rem;}/*!sc*/
|
||||
data-styled.g57[id="Header-style__BeatportLogoLarge-sc-54c5f6f1-7"]{content:"dNxFON,"}/*!sc*/
|
||||
.koymgh{display:flex;align-items:center;width:100%;max-width:28.375rem;padding:0 1.5rem;}/*!sc*/
|
||||
.koymgh >button>svg{margin-right:1.25rem;height:1.25rem;stroke-width:0.125rem;}/*!sc*/
|
||||
data-styled.g60[id="Header-style__HeaderSearchWrapper-sc-54c5f6f1-10"]{content:"koymgh,"}/*!sc*/
|
||||
.eCACUJ{display:flex;flex:1;width:100%;border:0.0625rem solid #343434;border-radius:0.25rem;background:#343434;}/*!sc*/
|
||||
.eCACUJ:focus-within{border:0.0625rem solid #01FF95;box-shadow:0px 0px 0px 3px rgba(1, 255, 149, 0.15);outline:0.125rem solid transparent;outline-offset:0.125rem;}/*!sc*/
|
||||
.eCACUJ div:first-child{display:flex;align-items:center;pointer-events:none;margin-left:0.3125rem;}/*!sc*/
|
||||
.eCACUJ button{display:flex;align-items:center;margin-right:0.625rem;}/*!sc*/
|
||||
.eCACUJ img{margin-left:0.3125rem;margin-top:0.0625rem;height:1rem;width:1rem;}/*!sc*/
|
||||
.eCACUJ input{height:2rem;margin-left:0.3125rem;width:100%;background:inherit;outline:none;border:none;}/*!sc*/
|
||||
.eCACUJ input::placeholder{color:#8C8C8C;}/*!sc*/
|
||||
data-styled.g61[id="Header-style__HeaderSearch-sc-54c5f6f1-11"]{content:"eCACUJ,"}/*!sc*/
|
||||
.dxGRL{font-size:max(100%,16px);}/*!sc*/
|
||||
.dxGRL:focus::placeholder{color:transparent;}/*!sc*/
|
||||
data-styled.g64[id="Search-style__SearchInput-sc-224b2fdd-1"]{content:"dxGRL,"}/*!sc*/
|
||||
.btldZz{position:fixed;top:3.75rem;left:calc(100% - 370px);width:22.5rem;display:flex;flex-direction:column;gap:0.5rem;font-size:0.875rem;line-height:1.25rem;z-index:60;}/*!sc*/
|
||||
@media (min-width: 580px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 768px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 1200px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 1440px){.btldZz{left:initial;right:calc((100% - 1371px) / 2);}}/*!sc*/
|
||||
data-styled.g98[id="Notifications-style__Wrapper-sc-6faed7f7-1"]{content:"btldZz,"}/*!sc*/
|
||||
.fUBPTR{padding:8px;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:12px;line-height:14px;color:#8C8C8C;}/*!sc*/
|
||||
.fUBPTR.p-0{padding:0;}/*!sc*/
|
||||
.fUBPTR.px-0{padding-left:0;padding-right:0;}/*!sc*/
|
||||
.fUBPTR.py-0{padding-top:0;padding-bottom:0;}/*!sc*/
|
||||
.fUBPTR .selectable:hover{opacity:1;}/*!sc*/
|
||||
data-styled.g99[id="Table-style__TableCell-sc-ef6c0b11-0"]{content:"fUBPTR,"}/*!sc*/
|
||||
.knBMpy{width:100%;display:flex;flex-direction:row;justify-content:space-between;align-items:center;height:40px;background:#343434;margin:0 0 2px;}/*!sc*/
|
||||
.knBMpy.selected{background:#494949;}/*!sc*/
|
||||
.knBMpy.tombstoned{background:#2D2D2D;}/*!sc*/
|
||||
.knBMpy.tombstoned a{pointer-events:none;}/*!sc*/
|
||||
.knBMpy.tombstoned:hover{background:#494949;}/*!sc*/
|
||||
.knBMpy svg.album-cover{display:none;position:absolute;margin-left:15px;}/*!sc*/
|
||||
.knBMpy:hover{background:#494949;position:relative;}/*!sc*/
|
||||
.knBMpy:hover div.chart-cell{color:white;}/*!sc*/
|
||||
.knBMpy:hover svg.album-cover{display:inline;}/*!sc*/
|
||||
.knBMpy:hover:hover::before{display:none;content:"";position:absolute;top:100%;left:50%;font-weight:400;font-size:12px;line-height:16px;border-radius:8px;transition:background 0.2s;z-index:1;background:#141414;color:#FFFFFF;margin-left:-5px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.knBMpy div:nth-child(2){flex-grow:1;}/*!sc*/
|
||||
data-styled.g100[id="Table-style__TableRow-sc-ef6c0b11-1"]{content:"knBMpy,"}/*!sc*/
|
||||
.kfOJRI{display:flex;flex-direction:column;justify-content:flex-start;align-items:flex-start;width:100%;}/*!sc*/
|
||||
data-styled.g101[id="Table-style__TableData-sc-ef6c0b11-2"]{content:"kfOJRI,"}/*!sc*/
|
||||
.iJZhfa{width:100%;display:flex;flex-direction:row;justify-content:space-between;align-items:center;}/*!sc*/
|
||||
data-styled.g102[id="Table-style__TableHeader-sc-ef6c0b11-3"]{content:"iJZhfa,"}/*!sc*/
|
||||
.eshZZd{width:100%;display:flex;flex-direction:column;justify-content:flex-start;align-items:flex-start;margin-top:12px;border-top:1px solid #343434;}/*!sc*/
|
||||
.eshZZd a:hover{text-decoration:underline;}/*!sc*/
|
||||
data-styled.g103[id="Table-style__Table-sc-ef6c0b11-4"]{content:"eshZZd,"}/*!sc*/
|
||||
.czBPNh{display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;flex-shrink:0;margin-left:0.25rem;}/*!sc*/
|
||||
data-styled.g104[id="Icons-styles__ExplicitIconContainer-sc-2b56028a-0"]{content:"czBPNh,"}/*!sc*/
|
||||
.fErSXU{display:flex;flex-direction:row;justify-content:center;align-items:center;float:inline-end;}/*!sc*/
|
||||
data-styled.g105[id="AddToCart-style__Control-sc-af8417c8-0"]{content:"fErSXU,"}/*!sc*/
|
||||
.cXOApT{border:none;margin:0;padding:0;font:inherit;background:transparent;cursor:pointer;max-width:100%;display:block;width:3.75rem;white-space:nowrap;background:#FF53A0;transition:background 0.2s ease-in-out,color 0.2s ease-in-out;color:#FFFFFF;border-radius:0.25rem 0rem 0rem 0.25rem;padding:0.25rem 0.5rem;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;font-size:0.75rem;line-height:1rem;text-align:center;}/*!sc*/
|
||||
.row .cXOApT{background:#913B61;color:#B3B3B3;}/*!sc*/
|
||||
.row:hover .cXOApT{background:#FF53A0;color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g108[id="AddToCart-style__PriceButton-sc-af8417c8-3"]{content:"cXOApT,"}/*!sc*/
|
||||
.ewatVW{border:none;margin:0;padding:0;font:inherit;background:transparent;cursor:pointer;background:#D64988;transition:background 0.2s ease-in-out;border-radius:0rem 0.25rem 0.25rem 0rem;padding:0.25rem;font-weight:700;font-size:0.75rem;line-height:1rem;width:1.5rem;min-width:1.5rem;}/*!sc*/
|
||||
.ewatVW i{margin:0.25rem;display:inline-block;vertical-align:middle;transition:border-top-color 0.2s ease-in-out;border-left:0.25rem solid transparent;border-right:0.25rem solid transparent;border-top:0.25rem solid currentColor;}/*!sc*/
|
||||
.ewatVW >div>svg{width:1rem;height:1rem;}/*!sc*/
|
||||
.row .ewatVW{background:#7C3655;}/*!sc*/
|
||||
.row:hover .ewatVW{background:#D64988;}/*!sc*/
|
||||
.row:hover .ewatVW i{border-top-color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g110[id="AddToCart-style__ArrowButton-sc-af8417c8-5"]{content:"ewatVW,"}/*!sc*/
|
||||
.fXQEET{display:none;flex-direction:center;justify-content:center;background:#141414;padding:4px;font-weight:400;font-size:12px;line-height:16px;border-radius:8px;transition:background 0.2s;background:#141414;color:#FFFFFF;text-align:center;padding:4px 4px;width:86px;position:absolute;z-index:2;filter:none;left:50%;margin-left:-43px;}/*!sc*/
|
||||
.fXQEET.top{bottom:155%;}/*!sc*/
|
||||
.fXQEET.top:after{position:absolute;content:"";top:100%;left:50%;margin-left:-5px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.top-left{bottom:155%;left:-60%;}/*!sc*/
|
||||
.fXQEET.top-left:after{position:absolute;content:"";top:100%;margin-right:-50px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.top-right{bottom:155%;right:-60%;}/*!sc*/
|
||||
.fXQEET.top-right:after{position:absolute;content:"";top:100%;margin-left:-50px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.bottom{top:115%;}/*!sc*/
|
||||
.fXQEET.bottom:before{position:absolute;content:"";bottom:100%;left:50%;margin-left:-5px;border:5px solid transparent;border-bottom-color:#141414;}/*!sc*/
|
||||
.fXQEET.left{right:calc(100% + 0.4rem);top:0;bottom:0;margin:auto 0;left:unset;margin-left:unset;height:1.7rem;padding:0.3rem 0.6rem;width:600px;max-width:max-content;}/*!sc*/
|
||||
.fXQEET.left:after{position:absolute;content:"";top:50%;left:100%;margin-top:-5px;border:5px solid transparent;border-left-color:#141414;}/*!sc*/
|
||||
data-styled.g114[id="Tooltip-style__TooltipContainer-sc-6f1e293b-0"]{content:"fXQEET,"}/*!sc*/
|
||||
.gIOTyw{position:relative;}/*!sc*/
|
||||
.gIOTyw:hover .Tooltip-style__TooltipContainer-sc-6f1e293b-0{display:flex;}/*!sc*/
|
||||
data-styled.g115[id="Tooltip-style__Wrapper-sc-6f1e293b-1"]{content:"gIOTyw,"}/*!sc*/
|
||||
.bdZnJo{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.bdZnJo svg:hover{color:#FFFFFF;}/*!sc*/
|
||||
.bdZnJo:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g116[id="AddToPlaylist-style__Control-sc-8c3c49e4-0"]{content:"bdZnJo,"}/*!sc*/
|
||||
.kNIDMA{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.kNIDMA svg:hover{color:#FFFFFF;}/*!sc*/
|
||||
.kNIDMA:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g117[id="AddToQueue-style__Control-sc-1bc05ec0-0"]{content:"kNIDMA,"}/*!sc*/
|
||||
.gkrZWH{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.gkrZWH:hover{--play-btn-bg:#5A5A5A;color:#FFFFFF;}/*!sc*/
|
||||
.gkrZWH:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g118[id="Play-style__Control-sc-6d5ab96d-0"]{content:"gkrZWH,"}/*!sc*/
|
||||
.gxWPvu{text-overflow:ellipsis;overflow:hidden;width:auto;}/*!sc*/
|
||||
data-styled.g123[id="Marquee-style__MarqueeElement-sc-b0373cc7-0"]{content:"gxWPvu,"}/*!sc*/
|
||||
.jkEITr{overflow:hidden;width:100%;display:grid;grid-auto-flow:column;position:relative;max-width:17rem;white-space:nowrap;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0{display:inline-block;width:max-content;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0:nth-child(2){margin-left:16px;visibility:hidden;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0{white-space:nowrap;}/*!sc*/
|
||||
.fjXTTJ{overflow:hidden;width:100%;display:grid;grid-auto-flow:column;position:relative;white-space:nowrap;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0{display:inline-block;width:max-content;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0:nth-child(2){margin-left:16px;visibility:hidden;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0{white-space:nowrap;}/*!sc*/
|
||||
data-styled.g124[id="Marquee-style__MarqueeWrapper-sc-b0373cc7-1"]{content:"jkEITr,fjXTTJ,"}/*!sc*/
|
||||
.gtSAGs{display:grid;grid-template-columns:1fr;width:100%;}/*!sc*/
|
||||
data-styled.g125[id="Marquee-style__MarqueeGrid-sc-b0373cc7-2"]{content:"gtSAGs,"}/*!sc*/
|
||||
.hoWvxf{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1.125rem;line-height:1rem;text-align:center;width:2.25rem;min-width:2.25rem;}/*!sc*/
|
||||
.hoWvxf.active{font-size:1rem;color:#FFFFFF;}/*!sc*/
|
||||
.izvHCz{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1rem;text-align:center;width:2.25rem;min-width:2.25rem;}/*!sc*/
|
||||
.izvHCz.active{font-size:1rem;color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g127[id="Tables-shared-style__TrackNo-sc-d94d4af-1"]{content:"hoWvxf,izvHCz,"}/*!sc*/
|
||||
.fBVOpY{right:0;top:0;height:0.875rem;padding:0 0.063rem;font-size:0.438rem;background:#01FF95;width:100%;color:#141414;position:absolute;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;}/*!sc*/
|
||||
data-styled.g128[id="Tables-shared-style__Exclusive-sc-d94d4af-2"]{content:"fBVOpY,"}/*!sc*/
|
||||
.iHAwiX{display:flex;}/*!sc*/
|
||||
data-styled.g129[id="Tables-shared-style__TitleRow-sc-d94d4af-3"]{content:"iHAwiX,"}/*!sc*/
|
||||
.jkqMEC{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;font-size:0.688rem;line-height:1rem;letter-spacing:0.005em;color:#FFFFFF;}/*!sc*/
|
||||
.jkqMEC span{font-weight:400;}/*!sc*/
|
||||
data-styled.g130[id="Tables-shared-style__ReleaseName-sc-d94d4af-4"]{content:"jkqMEC,"}/*!sc*/
|
||||
.febCWq{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:0.625rem;}/*!sc*/
|
||||
.febCWq a.artwork{position:relative;}/*!sc*/
|
||||
.febCWq img{aspect-ratio:1/1;object-fit:cover;object-position:center;}/*!sc*/
|
||||
.febCWq svg{transition:color 0.2s ease-in-out;color:inherit;}/*!sc*/
|
||||
.row:hover .febCWq svg{color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g131[id="Tables-shared-style__ArtworkAndControls-sc-d94d4af-5"]{content:"febCWq,"}/*!sc*/
|
||||
.gLWMaE .row{justify-content:flex-start;}/*!sc*/
|
||||
.gLWMaE .row.current{background:#494949;}/*!sc*/
|
||||
.gLWMaE .cell{padding-top:0;padding-bottom:0;}/*!sc*/
|
||||
.gLWMaE .cell a:hover{text-decoration:underline;}/*!sc*/
|
||||
.gLWMaE .cell.price{font-size:0.875rem;font-weight:700;color:#FFFFFF;}/*!sc*/
|
||||
.gLWMaE .controls{flex-shrink:0;padding-left:0;width:8.125rem;min-width:8.125rem;}/*!sc*/
|
||||
.gLWMaE .title{width:100%;flex-grow:1;overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .title .container{overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .title .container >div,.gLWMaE .title .container >a>span{overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .format{width:8.5rem;min-width:8.5rem;}/*!sc*/
|
||||
.gLWMaE .price{width:4.5rem;min-width:4.5rem;}/*!sc*/
|
||||
.gLWMaE .cart-actions{width:7.5rem;min-width:7.5rem;display:flex;justify-content:flex-end;}/*!sc*/
|
||||
.gLWMaE .card{width:7.5rem;min-width:7.5rem;display:flex;justify-content:flex-end;}/*!sc*/
|
||||
.gLWMaE.numbers .controls{width:11.125rem;min-width:11.125rem;}/*!sc*/
|
||||
data-styled.g132[id="Tables-shared-style__Wrapper-sc-d94d4af-6"]{content:"gLWMaE,"}/*!sc*/
|
||||
.kOioDs{display:flex;align-items:center;justify-content:flex-start;position:fixed;bottom:calc(0px + 1rem);right:calc(1rem + var(--scrollbar-width,0px));height:2.5rem;padding:0 1rem;font-weight:500;background:#39C0DE;border-radius:2rem;color:#141414;overflow:hidden;transition:max-width 0.3s ease-in-out;z-index:3;max-width:2.5rem;}/*!sc*/
|
||||
.kOioDs >svg{min-width:1rem;min-height:1rem;margin-left:-0.2rem;margin-right:0.5rem;}/*!sc*/
|
||||
.kOioDs >span{transition:opacity 0.4s ease-in-out;opacity:0;}/*!sc*/
|
||||
@media (min-width: 1024px){.kOioDs{bottom:1rem;}.kOioDs:hover{max-width:15rem;}.kOioDs:hover >span{opacity:1;}}/*!sc*/
|
||||
data-styled.g216[id="FeedbackButton-style__ButtonStyled-sc-75292206-0"]{content:"kOioDs,"}/*!sc*/
|
||||
.clBnoV{background-color:#343434;display:flex;min-height:19rem;justify-content:center;padding:3rem 1.25rem calc(3rem + 0px) 1.25rem;}/*!sc*/
|
||||
data-styled.g228[id="Footer-style__FooterContainer-sc-4f2908a0-0"]{content:"clBnoV,"}/*!sc*/
|
||||
.gxyJqq{max-width:90rem;width:100%;margin:0 auto;display:flex;justify-content:space-between;gap:2rem 1rem;flex-direction:column;}/*!sc*/
|
||||
@media (min-width: 768px){.gxyJqq{flex-direction:row;}}/*!sc*/
|
||||
@media (min-width: 1024px){.gxyJqq{gap:2rem;}}/*!sc*/
|
||||
@media (min-width: 1200px){.gxyJqq{padding:0 1.5rem;}}/*!sc*/
|
||||
data-styled.g229[id="Footer-style__FooterMaxWidth-sc-4f2908a0-1"]{content:"gxyJqq,"}/*!sc*/
|
||||
.iLPfwv{display:flex;flex-direction:column;gap:2.5rem;flex:0 1 auto;order:1;min-width:15rem;justify-content:space-between;}/*!sc*/
|
||||
@media (min-width: 1024px){.iLPfwv{min-width:16.25rem;}}/*!sc*/
|
||||
data-styled.g230[id="Footer-style__BrandColumnContainer-sc-4f2908a0-2"]{content:"iLPfwv,"}/*!sc*/
|
||||
.fsTWEU{display:flex;flex-direction:column;justify-content:start;gap:2.5rem;}/*!sc*/
|
||||
data-styled.g231[id="Footer-style__BrandWrapper-sc-4f2908a0-3"]{content:"fsTWEU,"}/*!sc*/
|
||||
.jvSVck{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding-right:0.625rem;}/*!sc*/
|
||||
data-styled.g232[id="Footer-style__SocialLinksContainer-sc-4f2908a0-4"]{content:"jvSVck,"}/*!sc*/
|
||||
.dhzLKu{display:flex;flex-wrap:wrap;flex:1 1 auto;row-gap:2rem;max-width:45rem;order:3;justify-content:space-between;gap:1rem;}/*!sc*/
|
||||
@media (min-width: 768px){.dhzLKu{flex-wrap:nowrap;order:2;}}/*!sc*/
|
||||
data-styled.g233[id="Footer-style__LinksColumnContainer-sc-4f2908a0-5"]{content:"dhzLKu,"}/*!sc*/
|
||||
.csFdrZ{width:100%;}/*!sc*/
|
||||
@media (min-width: 580px){.csFdrZ{max-width:10rem;}}/*!sc*/
|
||||
data-styled.g234[id="Footer-style__LinksGroup-sc-4f2908a0-6"]{content:"csFdrZ,"}/*!sc*/
|
||||
.dNLOmf{color:#FFFFFF;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1.25rem;margin-bottom:1rem;display:block;}/*!sc*/
|
||||
@media (min-width: 580px){.dNLOmf{font-size:0.75rem;line-height:1rem;}}/*!sc*/
|
||||
data-styled.g235[id="Footer-style__GroupTitle-sc-4f2908a0-7"]{content:"dNLOmf,"}/*!sc*/
|
||||
.gUWvku{list-style:none;padding:0;}/*!sc*/
|
||||
data-styled.g236[id="Footer-style__LinksList-sc-4f2908a0-8"]{content:"gUWvku,"}/*!sc*/
|
||||
.kWoBNH{color:#8C8C8C;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1.25rem;}/*!sc*/
|
||||
.kWoBNH a,.kWoBNH button{cursor:pointer;}/*!sc*/
|
||||
.kWoBNH button{text-align:start;font-weight:inherit;font-size:inherit;line-height:inherit;}/*!sc*/
|
||||
.kWoBNH .listItemLink:hover,.kWoBNH button:hover{color:rgba(255,255,255,0.9);}/*!sc*/
|
||||
@media (min-width: 580px){.kWoBNH{font-size:0.75rem;line-height:1rem;}}/*!sc*/
|
||||
data-styled.g237[id="Footer-style__ListItem-sc-4f2908a0-9"]{content:"kWoBNH,"}/*!sc*/
|
||||
.lesWfY{min-width:10.5rem;min-height:3.25rem;}/*!sc*/
|
||||
data-styled.g238[id="Footer-style__LogoWrapper-sc-4f2908a0-10"]{content:"lesWfY,"}/*!sc*/
|
||||
.UFZKa{display:block;color:#8C8C8C;font-size:0.75rem;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;}/*!sc*/
|
||||
data-styled.g239[id="Footer-style__Copyright-sc-4f2908a0-11"]{content:"UFZKa,"}/*!sc*/
|
||||
.ghoRKa{display:flex;flex-direction:row;gap:1rem;order:2;}/*!sc*/
|
||||
@media (min-width: 768px){.ghoRKa{flex-direction:column;}}/*!sc*/
|
||||
.ghoRKa a{width:fit-content;}/*!sc*/
|
||||
data-styled.g240[id="Footer-style__MobileAppLinks-sc-4f2908a0-12"]{content:"ghoRKa,"}/*!sc*/
|
||||
.fSmyMD{display:flex;flex-direction:row;min-width:171px;width:208px;max-width:320px;position:sticky;max-height:calc(100vh - (0px + 60px + 3.875rem));top:calc(60px + 1.75rem);margin-left:1.5rem;transition:max-height 0.3s ease-in-out;overflow:hidden;}/*!sc*/
|
||||
.fSmyMD .sidebar-content{background-color:#343434;flex:1;}/*!sc*/
|
||||
.fSmyMD .sidebar-resizer{flex-grow:0;flex-shrink:0;flex-basis:0.375rem;justify-self:flex-end;cursor:col-resize;resize:horizontal;}/*!sc*/
|
||||
.fSmyMD .sidebar-resizer:hover{width:0.1875rem;background-color:#141414;}/*!sc*/
|
||||
data-styled.g245[id="Sidebar-style__DesktopSidebar-sc-ec5c0dc3-0"]{content:"fSmyMD,"}/*!sc*/
|
||||
.kARIcT{position:relative;display:flex;align-items:center;font-size:1.25rem;padding:0.25rem 1.5rem;line-height:1.75rem;transition:all 0.1s ease-in-out;width:max-content;cursor:pointer;}/*!sc*/
|
||||
@media (min-width: 1200px){.kARIcT{border-bottom:0.0625rem solid #2D2D2D;padding:0.75rem 1.25rem;margin-left:0;font-size:0.875rem;line-height:1.5rem;width:auto;}.kARIcT:hover{cursor:pointer;background-color:#2D2D2D;box-shadow:inset 0.125rem 0 0 0 #01FF95;}}/*!sc*/
|
||||
.kARIcT svg{margin-right:0.5rem;width:1.5rem!important;height:auto!important;}/*!sc*/
|
||||
.kARIcT .forward-arrow{width:0.9rem!important;height:0.9rem!important;margin-left:0.375rem;}/*!sc*/
|
||||
@media (min-width: 1200px){.kARIcT svg{margin-right:0.375rem;width:1rem!important;height:1rem!important;}}/*!sc*/
|
||||
data-styled.g252[id="Sidebar-style__SidebarLink-sc-ec5c0dc3-7"]{content:"kARIcT,"}/*!sc*/
|
||||
.bOxODG{margin:0.5rem auto;background-color:#343434;width:calc(100% - 2 * 0.9375rem);height:0.0625rem;}/*!sc*/
|
||||
data-styled.g255[id="Sidebar-style__HorizontalBar-sc-ec5c0dc3-10"]{content:"bOxODG,"}/*!sc*/
|
||||
.cpMYKr{color:#01FF95;text-transform:capitalize;font-weight:500;font-size:1rem;align-items:flex-start;}/*!sc*/
|
||||
.cpMYKr a{display:flex;align-items:center;}/*!sc*/
|
||||
.cpMYKr:hover{color:#01CC77;}/*!sc*/
|
||||
@media (min-width: 1200px){.cpMYKr{font-size:0.875rem;}}/*!sc*/
|
||||
.cpMYKr svg{margin-right:0.375rem;}/*!sc*/
|
||||
data-styled.g258[id="Sidebar-style__CreatePlaylist-sc-ec5c0dc3-13"]{content:"cpMYKr,"}/*!sc*/
|
||||
.cgwoae{flex-grow:1;display:flex;flex-direction:column;min-height:0;padding:0 1.25rem;}/*!sc*/
|
||||
.cgwoae .element{margin:0.8rem 0;}/*!sc*/
|
||||
.cgwoae .mobile-playlist-element{margin-bottom:1.5rem;}/*!sc*/
|
||||
.cgwoae .mobile-playlist-element:first-of-type{margin-top:1rem;}/*!sc*/
|
||||
.cgwoae.content{height:3.75rem;min-height:3.75rem;max-height:3.75rem;}/*!sc*/
|
||||
.cgwoae.scrollable{flex-grow:1;overflow:auto;min-height:0;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar{width:0rem;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar-track{background-color:#343434;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar-thumb{background-color:#2D2D2D;}/*!sc*/
|
||||
.cgwoae.scrollable:hover::-webkit-scrollbar{width:0.3125rem;}/*!sc*/
|
||||
data-styled.g260[id="Sidebar-style__PlaylistSectionContent-sc-ec5c0dc3-15"]{content:"cgwoae,"}/*!sc*/
|
||||
.cXCcbl{position:relative;height:calc(100% - 12.9375rem);width:100%;}/*!sc*/
|
||||
.cXCcbl div.wrapper{position:absolute;top:0;bottom:0;left:0;width:100%;display:flex;flex-direction:column;}/*!sc*/
|
||||
data-styled.g261[id="Sidebar-style__PlaylistSectionContainer-sc-ec5c0dc3-16"]{content:"cXCcbl,"}/*!sc*/
|
||||
.ccUqKr{position:relative;width:100%;background:#262626;min-height:calc(100vh - 60px);display:flex;justify-content:center;}/*!sc*/
|
||||
data-styled.g262[id="MainLayout-style__MainWrapper-sc-909801dd-0"]{content:"ccUqKr,"}/*!sc*/
|
||||
.eEEXCV{padding:1.75rem 0;width:100%;max-width:1420px;display:flex;margin-top:60px;}/*!sc*/
|
||||
data-styled.g263[id="MainLayout-style__Main-sc-909801dd-1"]{content:"eEEXCV,"}/*!sc*/
|
||||
.AtWIy{width:100%;padding:0 1.5rem;display:flex;flex-direction:column;justify-content:start;}/*!sc*/
|
||||
@media (min-width: 1024px){.AtWIy{width:calc( 100% - 208px );}}/*!sc*/
|
||||
data-styled.g264[id="MainLayout-style__MainContent-sc-909801dd-2"]{content:"AtWIy,"}/*!sc*/
|
||||
.iLohBb{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:500;font-size:11px;line-height:16px;letter-spacing:0.005em;color:#39C0DE;}/*!sc*/
|
||||
.iLohBb a:hover{text-decoration:underline;}/*!sc*/
|
||||
data-styled.g265[id="ArtistNames-sc-881a9c20-0"]{content:"iLohBb,"}/*!sc*/
|
||||
.iKReJc{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:0.75rem;height:2.125rem;padding-right:0.75rem;border-radius:1.406rem;transition:background 0.2s;cursor:pointer;}/*!sc*/
|
||||
data-styled.g497[id="CollectionControls-style__Controls-sc-33f388f9-0"]{content:"iKReJc,"}/*!sc*/
|
||||
.fIvIm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;}/*!sc*/
|
||||
data-styled.g498[id="CollectionControls-style__Wrapper-sc-33f388f9-1"]{content:"fIvIm,"}/*!sc*/
|
||||
.dMoKwU a.artwork>img{min-width:2.375rem;}/*!sc*/
|
||||
.dMoKwU .droppable{width:100%;}/*!sc*/
|
||||
.dMoKwU .cell.bpm a{white-space:nowrap;overflow:hidden;display:block;text-overflow:ellipsis;}/*!sc*/
|
||||
.dMoKwU .cell.format{padding-left:0;}/*!sc*/
|
||||
.dMoKwU .label{width:7.5rem;min-width:7.5rem;}/*!sc*/
|
||||
.dMoKwU .date{width:7.5rem;min-width:7.5rem;}/*!sc*/
|
||||
.dMoKwU .bpm{width:9.375rem;min-width:9.375rem;display:block;text-overflow:elipsis;white-space:no-wrap;}/*!sc*/
|
||||
.dMoKwU .price{width:3.75rem;min-width:3.75rem;}/*!sc*/
|
||||
.dMoKwU i.arrow{margin:0.25rem;display:inline-block;vertical-align:middle;width:0;height:0;border-left:0.25rem solid transparent;border-right:0.25rem solid transparent;border-top:0.25rem solid #01FF95;}/*!sc*/
|
||||
.dMoKwU i.arrow.desc{transform:rotate(180deg);}/*!sc*/
|
||||
data-styled.g547[id="TracksTable-style__TracksWrapper-sc-fcbf7f55-0"]{content:"dMoKwU,"}/*!sc*/
|
||||
.cNqYNo{padding-bottom:0.1rem;font-weight:400;font-size:0.875rem;line-height:1.25rem;color:#8C8C8C;}/*!sc*/
|
||||
data-styled.g617[id="TitleControls-style__PreText-sc-10266053-1"]{content:"cNqYNo,"}/*!sc*/
|
||||
.kHnlXM{padding-bottom:0;}/*!sc*/
|
||||
@media (min-width: 768px){.kHnlXM{padding-right:1rem;}}/*!sc*/
|
||||
data-styled.g618[id="TitleControls-style__TitleContainer-sc-10266053-2"]{content:"kHnlXM,"}/*!sc*/
|
||||
.eZXJZk{padding-bottom:.25rem;}/*!sc*/
|
||||
.eZXJZk.with-pre{padding-top:1.25rem;}/*!sc*/
|
||||
data-styled.g619[id="TitleControls-style__Controls-sc-10266053-3"]{content:"eZXJZk,"}/*!sc*/
|
||||
.kigZFc{width:100%;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:.5rem;}/*!sc*/
|
||||
.kigZFc>:last-child{margin-left:auto;}/*!sc*/
|
||||
@media (min-width: 768px){.kigZFc{display:flex;}.kigZFc>:last-child{margin-left:inherit;}}/*!sc*/
|
||||
data-styled.g620[id="TitleControls-style__Container-sc-10266053-4"]{content:"kigZFc,"}/*!sc*/
|
||||
</style></head><body class="__variable_a8b512"><div id="__next"><div><div id="bp-header"><div class="Header-style__HeaderWrapper-sc-54c5f6f1-0 gZFJrf header-fixed"><div class="Header-style__DesktopHeader-sc-54c5f6f1-2 kFaHkN"><ul class="Header-style__LeftRegion-sc-54c5f6f1-3 eRPXdM"><li><a href="/"><img src="/images/beatport-logo-icon.svg" alt="Beatport Logo" class="Header-style__BeatportLogoLarge-sc-54c5f6f1-7 dNxFON"/></a></li><li class="header_item dropdown_toggle genre_toggle"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq unstyled" id="genre-dropdown-menu-button" aria-haspopup="menu" aria-expanded="false" aria-controls="genre-dropdown-menu" aria-label="Genres menu"><span class="rotatable">Genres<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-drop-down" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#drop-down"></use></svg></span></button><div id="genre-dropdown-menu" aria-labelledby="genre-dropdown-menu-button" data-visible="false" data-dropdown-type="genre" class="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0 dkwGxt"><div role="menu" aria-labelledby="genres-dropdown-menu" class="HeaderGenreSubMenu-style__GenreDropDownGrid-sc-5f01336e-4 kNtdbg"></div></div></li><li class="header_item"><a data-testid="header-mainnav-link-staffpicks" href="/staff-picks"><span>Dig Deeper</span></a></li><li class="header_item"><a data-testid="header-mainnav-link-djapp" target="_blank" rel="noopener noreferrer" href="https://dj.beatport.com/"><span>DJ App<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item"><a data-testid="header-mainnav-link-beatport" target="_blank" rel="noopener noreferrer" href="https://www.beatportal.com/"><span>Beatportal<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item"><a data-testid="header-mainnav-link-artists-labels" target="_blank" rel="noopener noreferrer" href="https://greenroom.beatport.com/"><span>For Artists & Labels<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item dropdown_toggle"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq unstyled" id="events-dropdown-menu-button" aria-haspopup="menu" aria-expanded="false" aria-controls="events-dropdown-menu" aria-label="Events menu"><span class="rotatable">Events<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-drop-down" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#drop-down"></use></svg></span></button><div id="events-dropdown-menu" aria-labelledby="events-dropdown-menu-button" data-visible="false" data-dropdown-type="events" class="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0 dkwGxt"><div role="menu" class="HeaderEventsSubMenu-style__EventsSubMenuWrapper-sc-75ecafea-0 jgwRkR"><a href="http://greenroom.beatport.com/tickets?utm_medium=bp_store_banner&utm_source=bp_store&utm_campaign=2025_evergreen_greenroom&utm_content=banner" target="_blank" rel="noopener noreferrer" role="menuitem">For Promoters<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></a><a href="http://beatportal.com/events?utm_source=beatport&utm_medium=banner&utm_campaign=header_banner" target="_blank" rel="noopener noreferrer" role="menuitem">Upcoming Events<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></a></div></div></li></ul><ul class="Header-style__RightRegion-sc-54c5f6f1-4 exexVn"><li class="search"><div id="site-search" class="Header-style__HeaderSearchWrapper-sc-54c5f6f1-10 koymgh"><div class="Header-style__HeaderSearch-sc-54c5f6f1-11 eCACUJ"><div><svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-search" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#search"></use></svg></div><input id="search-field" name="search-field" data-testid="header-search-input" placeholder="Search" aria-label="Search" type="search" autoComplete="no-username" class="Search-style__SearchInput-sc-224b2fdd-1 dxGRL" value=""/></div></div></li><li class="subscription_button"><a href="/subscriptions">Add Streaming</a></li><li class="header_item account"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq linkLine truncated
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Test per core.beatport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core import beatport
|
||||
|
||||
|
||||
class TestListGenres:
|
||||
def test_returns_list_of_dicts(self):
|
||||
result = beatport.list_genres()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 10 # almeno 10 generi
|
||||
for g in result:
|
||||
assert set(g.keys()) == {"slug", "id", "name"}
|
||||
assert isinstance(g["slug"], str) and g["slug"]
|
||||
assert isinstance(g["id"], int) and g["id"] > 0
|
||||
assert isinstance(g["name"], str) and g["name"]
|
||||
|
||||
def test_sorted_alphabetically_by_name(self):
|
||||
result = beatport.list_genres()
|
||||
names = [g["name"] for g in result]
|
||||
assert names == sorted(names, key=str.casefold)
|
||||
|
||||
def test_melodic_house_techno_present(self):
|
||||
result = beatport.list_genres()
|
||||
slugs = [g["slug"] for g in result]
|
||||
assert "melodic-house-techno" in slugs
|
||||
|
||||
|
||||
class TestBeatportTrack:
|
||||
def test_display_format(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1,
|
||||
title="Hot Sauce",
|
||||
mix="Extended",
|
||||
artists="Kapuchon, Miss Monique & GLZ",
|
||||
duration_sec=336,
|
||||
beatport_id=12345,
|
||||
)
|
||||
assert t.display == "Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)"
|
||||
|
||||
def test_display_pads_seconds(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1, title="X", mix="Y", artists="A",
|
||||
duration_sec=65, beatport_id=1,
|
||||
)
|
||||
assert t.display.endswith("(1:05)")
|
||||
|
||||
def test_spotify_query(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1,
|
||||
title="Hot Sauce",
|
||||
mix="Extended",
|
||||
artists="Kapuchon, Miss Monique & GLZ",
|
||||
duration_sec=336,
|
||||
beatport_id=12345,
|
||||
)
|
||||
assert t.spotify_query == "Kapuchon, Miss Monique & GLZ Hot Sauce"
|
||||
|
||||
def test_is_frozen(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1, title="X", mix="Y", artists="A",
|
||||
duration_sec=1, beatport_id=1,
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
t.title = "Z" # frozen=True impedisce mutazione
|
||||
|
||||
|
||||
class TestExtractNextData:
|
||||
def test_extracts_json_from_valid_html(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
data = beatport._extract_next_data(html)
|
||||
assert isinstance(data, dict)
|
||||
assert "props" in data
|
||||
|
||||
def test_missing_script_raises(self):
|
||||
with pytest.raises(beatport.BeatportParseError, match="__NEXT_DATA__ non trovato"):
|
||||
beatport._extract_next_data("<html><body>nulla</body></html>")
|
||||
|
||||
def test_malformed_json_raises(self):
|
||||
broken = '<script id="__NEXT_DATA__" type="application/json">{not: valid}</script>'
|
||||
with pytest.raises(beatport.BeatportParseError, match="JSON malformato"):
|
||||
beatport._extract_next_data(broken)
|
||||
|
||||
|
||||
class TestParseTracks:
|
||||
def test_extracts_100_tracks(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
data = beatport._extract_next_data(html)
|
||||
tracks = beatport._parse_tracks(data)
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_positions_are_sequential_1_to_100(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
tracks = beatport._parse_tracks(beatport._extract_next_data(html))
|
||||
positions = [t.position for t in tracks]
|
||||
assert positions == list(range(1, 101))
|
||||
|
||||
def test_track_shape(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
tracks = beatport._parse_tracks(beatport._extract_next_data(html))
|
||||
first = tracks[0]
|
||||
assert isinstance(first, beatport.BeatportTrack)
|
||||
assert first.title
|
||||
assert first.artists
|
||||
assert first.duration_sec > 0
|
||||
assert first.beatport_id > 0
|
||||
|
||||
def test_schema_missing_results_raises(self):
|
||||
with pytest.raises(beatport.BeatportParseError, match="results"):
|
||||
beatport._parse_tracks({"props": {"pageProps": {}}})
|
||||
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
|
||||
def _mock_response(text: str, status_code: int = 200) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.text = text
|
||||
resp.status_code = status_code
|
||||
def _raise():
|
||||
if status_code >= 400:
|
||||
raise Exception(f"HTTP {status_code}")
|
||||
resp.raise_for_status = _raise
|
||||
return resp
|
||||
|
||||
|
||||
class TestFetchTop100:
|
||||
@pytest.fixture
|
||||
def fixture_html(self, fixtures_dir):
|
||||
return (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
|
||||
def test_success_returns_100_tracks(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
tracks = beatport.fetch_top100("melodic-house-techno")
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_invalid_slug_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="slug"):
|
||||
beatport.fetch_top100("not-a-real-genre")
|
||||
|
||||
def test_5xx_retries_and_raises_unreachable(self):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response("", 503)
|
||||
with patch("core.beatport.time.sleep"): # skip backoff
|
||||
with pytest.raises(beatport.BeatportUnreachableError):
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 3 # 1 + 2 retry
|
||||
|
||||
def test_cache_hit_within_ttl(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_cache_expires_after_ttl(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
with freeze_time("2026-01-01 10:00:00") as frozen:
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
frozen.tick(delta=beatport._CACHE_TTL_SEC + 1)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_force_refresh_bypasses_cache(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
beatport.fetch_top100("melodic-house-techno", force_refresh=True)
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_uses_chrome_impersonation(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
call_kwargs = mock_get.call_args.kwargs
|
||||
assert "impersonate" in call_kwargs
|
||||
assert call_kwargs["impersonate"].startswith("chrome")
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Test per core.spotify_client.search_track()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import spotify_client
|
||||
|
||||
|
||||
class TestSearchTrack:
|
||||
@responses.activate
|
||||
def test_returns_track_dict_when_found(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={
|
||||
"tracks": {
|
||||
"items": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"name": "Hot Sauce",
|
||||
"artists": [{"name": "Kapuchon"}],
|
||||
"external_urls": {"spotify": "https://open.spotify.com/track/abc123"},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_track("fake-token", "Kapuchon Hot Sauce")
|
||||
assert result is not None
|
||||
assert result["id"] == "abc123"
|
||||
assert result["url"] == "https://open.spotify.com/track/abc123"
|
||||
|
||||
@responses.activate
|
||||
def test_returns_none_when_no_results(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_track("fake-token", "no-match")
|
||||
assert result is None
|
||||
|
||||
@responses.activate
|
||||
def test_sends_bearer_token(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_track("my-token", "q")
|
||||
assert responses.calls[0].request.headers["Authorization"] == "Bearer my-token"
|
||||
|
||||
@responses.activate
|
||||
def test_query_params_correct(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_track("t", "Kapuchon Hot Sauce")
|
||||
params = responses.calls[0].request.params
|
||||
assert params["q"] == "Kapuchon Hot Sauce"
|
||||
assert params["type"] == "track"
|
||||
assert params["limit"] == "1"
|
||||
|
||||
|
||||
class TestSearchTracks:
|
||||
@responses.activate
|
||||
def test_returns_list_of_tracks(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={
|
||||
"tracks": {
|
||||
"items": [
|
||||
{
|
||||
"id": f"id{i}",
|
||||
"name": f"Track {i}",
|
||||
"artists": [{"name": "Solomun"}],
|
||||
"album": {"name": "Album X"},
|
||||
"duration_ms": 300000 + i * 1000,
|
||||
"external_urls": {"spotify": f"https://open.spotify.com/track/id{i}"},
|
||||
}
|
||||
for i in range(50)
|
||||
]
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "solomun", limit=50)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 50
|
||||
first = result[0]
|
||||
assert first["id"] == "id0"
|
||||
assert first["name"] == "Track 0"
|
||||
assert first["artists"] == "Solomun"
|
||||
assert first["album"] == "Album X"
|
||||
assert first["duration_sec"] == 300
|
||||
assert first["url"] == "https://open.spotify.com/track/id0"
|
||||
|
||||
@responses.activate
|
||||
def test_multiple_artists_joined_with_comma(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": [{
|
||||
"id": "x", "name": "n",
|
||||
"artists": [{"name": "A"}, {"name": "B"}, {"name": "C"}],
|
||||
"album": {"name": "Alb"},
|
||||
"duration_ms": 60000,
|
||||
"external_urls": {"spotify": "u"},
|
||||
}]}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "q", limit=1)
|
||||
assert result[0]["artists"] == "A, B, C"
|
||||
|
||||
@responses.activate
|
||||
def test_empty_query_returns_empty_list(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "no-match", limit=50)
|
||||
assert result == []
|
||||
|
||||
@responses.activate
|
||||
def test_query_params_include_limit(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_tracks("t", "q", limit=25)
|
||||
params = responses.calls[0].request.params
|
||||
assert params["q"] == "q"
|
||||
assert params["type"] == "track"
|
||||
assert params["limit"] == "25"
|
||||
|
||||
|
||||
class TestSearchArtistDiscography:
|
||||
@responses.activate
|
||||
def test_exact_name_match_beats_popular(self):
|
||||
# 3 candidati: uno esatto (case-insensitive), altri popolari
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [
|
||||
{"id": "pop", "name": "Solomun Tribute", "popularity": 90},
|
||||
{"id": "exact", "name": "SOLOMUN", "popularity": 60},
|
||||
{"id": "unrelated", "name": "Other", "popularity": 70},
|
||||
]}},
|
||||
status=200,
|
||||
)
|
||||
# Mock top-tracks vuoto per non allungare il test
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/exact/top-tracks",
|
||||
json={"tracks": []},
|
||||
status=200,
|
||||
)
|
||||
# Mock albums vuoto
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/exact/albums",
|
||||
json={"items": []},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_artist_discography("t", "Solomun")
|
||||
assert result == []
|
||||
# Verifica che sia stato chiamato l'artista "exact", non "pop"
|
||||
top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url]
|
||||
assert len(top_tracks_calls) == 1
|
||||
assert "/exact/top-tracks" in top_tracks_calls[0].request.url
|
||||
|
||||
@responses.activate
|
||||
def test_falls_back_to_most_popular_if_no_exact_match(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [
|
||||
{"id": "a1", "name": "Solomun Fanpage", "popularity": 30},
|
||||
{"id": "a2", "name": "Solomun Live", "popularity": 80},
|
||||
]}},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/a2/top-tracks",
|
||||
json={"tracks": []},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/a2/albums",
|
||||
json={"items": []},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_artist_discography("t", "solomun")
|
||||
top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url]
|
||||
assert "/a2/top-tracks" in top_tracks_calls[0].request.url
|
||||
|
||||
@responses.activate
|
||||
def test_raises_when_no_artist_found(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Artista"):
|
||||
spotify_client.search_artist_discography("t", "asdgjhkasdgj")
|
||||
|
||||
@responses.activate
|
||||
def test_combines_top_tracks_and_album_tracks(self):
|
||||
# 1 artista esatto
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [{"id": "artX", "name": "artX", "popularity": 50}]}},
|
||||
status=200,
|
||||
)
|
||||
# 3 top-tracks
|
||||
top_tracks_data = {"tracks": [
|
||||
{
|
||||
"id": f"t{i}", "name": f"Top{i}",
|
||||
"artists": [{"name": "artX"}],
|
||||
"album": {"name": "AlbTop"},
|
||||
"duration_ms": 200000,
|
||||
"external_urls": {"spotify": f"u{i}"},
|
||||
} for i in range(3)
|
||||
]}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/artX/top-tracks",
|
||||
json=top_tracks_data,
|
||||
status=200,
|
||||
)
|
||||
# 2 album
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/artX/albums",
|
||||
json={"items": [
|
||||
{"id": "alb1", "name": "Album 1"},
|
||||
{"id": "alb2", "name": "Album 2"},
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
# album 1: 2 tracce
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb1/tracks",
|
||||
json={"items": [
|
||||
{
|
||||
"id": f"a1t{i}", "name": f"Alb1Track{i}",
|
||||
"artists": [{"name": "artX"}],
|
||||
"duration_ms": 180000,
|
||||
"external_urls": {"spotify": f"a1u{i}"},
|
||||
} for i in range(2)
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
# album 2: 1 traccia
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb2/tracks",
|
||||
json={"items": [
|
||||
{
|
||||
"id": "a2t0", "name": "Alb2Track0",
|
||||
"artists": [{"name": "artX"}],
|
||||
"duration_ms": 240000,
|
||||
"external_urls": {"spotify": "a2u0"},
|
||||
}
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
with patch("core.spotify_client.time.sleep"): # no wait
|
||||
result = spotify_client.search_artist_discography("t", "artX")
|
||||
# 3 top + 2 alb1 + 1 alb2 = 6
|
||||
assert len(result) == 6
|
||||
titles = {r["name"] for r in result}
|
||||
assert "Top0" in titles
|
||||
assert "Alb1Track0" in titles
|
||||
assert "Alb2Track0" in titles
|
||||
|
||||
@responses.activate
|
||||
def test_dedupe_across_top_and_album(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [{"id": "aX", "name": "aX", "popularity": 50}]}},
|
||||
status=200,
|
||||
)
|
||||
# Stesso track name+artist in top-tracks e in album (id diverso)
|
||||
common = {
|
||||
"name": "Same Song",
|
||||
"artists": [{"name": "aX"}],
|
||||
"album": {"name": "OG Album"},
|
||||
"duration_ms": 200000,
|
||||
"external_urls": {"spotify": "u"},
|
||||
}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/aX/top-tracks",
|
||||
json={"tracks": [{**common, "id": "top-id"}]},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/aX/albums",
|
||||
json={"items": [{"id": "alb", "name": "OG"}]},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb/tracks",
|
||||
json={"items": [{**common, "id": "alb-id"}]},
|
||||
status=200,
|
||||
)
|
||||
with patch("core.spotify_client.time.sleep"):
|
||||
result = spotify_client.search_artist_discography("t", "aX")
|
||||
assert len(result) == 1
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Test per core.youtube_search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core import youtube_search
|
||||
|
||||
|
||||
def _mock_ytdlp_result(entries: list) -> MagicMock:
|
||||
"""Mock subprocess.CompletedProcess con JSON stub."""
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = json.dumps({"entries": entries})
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
|
||||
class TestSearchYoutube:
|
||||
def test_parses_entries(self):
|
||||
entries = [
|
||||
{
|
||||
"id": "abc123",
|
||||
"url": "https://www.youtube.com/watch?v=abc123",
|
||||
"title": "Kapuchon - Hot Sauce (Official Video)",
|
||||
"uploader": "Kapuchon Official",
|
||||
"duration": 336,
|
||||
},
|
||||
{
|
||||
"id": "def456",
|
||||
"url": "https://www.youtube.com/watch?v=def456",
|
||||
"title": "Solomun @ Cocoricò 2024",
|
||||
"uploader": "Cocoricò",
|
||||
"duration": 3600,
|
||||
},
|
||||
]
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result(entries)):
|
||||
result = youtube_search.search_youtube("kapuchon hot sauce", limit=50)
|
||||
assert len(result) == 2
|
||||
assert result[0]["title"] == "Kapuchon - Hot Sauce (Official Video)"
|
||||
assert result[0]["channel"] == "Kapuchon Official"
|
||||
assert result[0]["duration_sec"] == 336
|
||||
assert result[0]["url"] == "https://www.youtube.com/watch?v=abc123"
|
||||
|
||||
def test_empty_entries_returns_empty(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result([])):
|
||||
result = youtube_search.search_youtube("no-match", limit=50)
|
||||
assert result == []
|
||||
|
||||
def test_raises_when_ytdlp_missing(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="yt-dlp"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
|
||||
def test_command_uses_ytsearch_with_limit(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result([])) as mock_run:
|
||||
youtube_search.search_youtube("solomun", limit=30)
|
||||
args = mock_run.call_args.args[0]
|
||||
assert args[0] == "/fake/yt-dlp"
|
||||
search_arg = [a for a in args if a.startswith("ytsearch")]
|
||||
assert search_arg == ["ytsearch30:solomun"]
|
||||
|
||||
def test_timeout_raises_runtime_error(self):
|
||||
import subprocess as sp
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", side_effect=sp.TimeoutExpired("yt-dlp", 30)):
|
||||
with pytest.raises(RuntimeError, match="[Tt]imeout"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
|
||||
def test_nonzero_exit_raises(self):
|
||||
bad = MagicMock()
|
||||
bad.returncode = 1
|
||||
bad.stdout = ""
|
||||
bad.stderr = "some yt-dlp error"
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=bad):
|
||||
with pytest.raises(RuntimeError, match="yt-dlp"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
+192
-22
@@ -49,6 +49,16 @@
|
||||
--text-2: #B3B3B3;
|
||||
--text-3: #727272;
|
||||
|
||||
/* Varianti brand per uso come TESTO (foreground). In dark restano vibranti
|
||||
come le base; in light diventano scure per contrasto AA su sfondo bianco. */
|
||||
--green-text: var(--green);
|
||||
--blue-text: var(--blue);
|
||||
--purple-text: var(--purple);
|
||||
--pink-text: var(--pink);
|
||||
--amber-text: var(--amber);
|
||||
--indigo-text: var(--indigo);
|
||||
--rec-red-text: var(--rec-red);
|
||||
|
||||
--r-sm: 8px;
|
||||
--r-md: 12px;
|
||||
--r-lg: 14px;
|
||||
@@ -84,6 +94,15 @@ html[data-theme="light"] {
|
||||
--rec-red-dim: #FECACA;
|
||||
--indigo-dim: #C7D2FE;
|
||||
|
||||
/* Varianti brand per testo: scurite per contrasto AA su fondo bianco */
|
||||
--green-text: #0F7A38;
|
||||
--blue-text: #1D6BD3;
|
||||
--purple-text: #7E22CE;
|
||||
--pink-text: #BE185D;
|
||||
--amber-text: #B45309;
|
||||
--indigo-text: #4338CA;
|
||||
--rec-red-text: #B91C1C;
|
||||
|
||||
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
|
||||
|
||||
@@ -192,13 +211,13 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #1A1A1A;
|
||||
background: var(--bg-card-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #1F1F1F;
|
||||
color: var(--green);
|
||||
background: var(--bg-card-2);
|
||||
color: var(--green-text);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
@@ -214,7 +233,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
}
|
||||
|
||||
.footer-by {
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
@@ -254,7 +273,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
|
||||
.quota-plan {
|
||||
font-size: 11px;
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
@@ -314,7 +333,9 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
HERO
|
||||
=============================== */
|
||||
.hero {
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
border-radius: var(--r-lg);
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 24px;
|
||||
@@ -469,7 +490,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
|
||||
.badge {
|
||||
background: rgba(29, 185, 84, 0.15);
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
@@ -495,8 +516,8 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
.input::placeholder { color: var(--text-3); }
|
||||
|
||||
.input:focus {
|
||||
background: #333;
|
||||
border-color: rgba(29, 185, 84, 0.4);
|
||||
background: var(--bg-input-hover);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.input-sm { max-width: 200px; }
|
||||
@@ -663,8 +684,8 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.percent.green { color: var(--green); }
|
||||
.percent.blue { color: var(--blue); }
|
||||
.percent.green { color: var(--green-text); }
|
||||
.percent.blue { color: var(--blue-text); }
|
||||
.percent.pink { color: var(--pink-hover); }
|
||||
|
||||
.progress {
|
||||
@@ -711,10 +732,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log .l-ok { color: var(--green); }
|
||||
.log .l-ok { color: var(--green-text); }
|
||||
.log .l-err { color: var(--red); }
|
||||
.log .l-info { color: var(--blue); }
|
||||
.log .l-warn { color: #FDCB6E; }
|
||||
.log .l-info { color: var(--blue-text); }
|
||||
.log .l-warn { color: var(--amber-text); }
|
||||
.log .l-muted { color: var(--text-3); }
|
||||
|
||||
/* ===============================
|
||||
@@ -774,7 +795,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.hint strong { color: var(--text); font-weight: 700; }
|
||||
|
||||
.hint code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-input);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
@@ -838,7 +859,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
INFO ROW (Upgrade tab)
|
||||
=============================== */
|
||||
.info-row {
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
@@ -862,7 +883,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.update-status.ok { color: var(--green); }
|
||||
.update-status.ok { color: var(--green-text); }
|
||||
.update-status.warn { color: #FDCB6E; }
|
||||
.update-status.err { color: var(--red); }
|
||||
|
||||
@@ -873,15 +894,27 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
|
||||
sempre visibile mentre l'utente scrolla la tab Impostazioni. Niente
|
||||
ricerca del pulsante in coda. */
|
||||
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
|
||||
sempre visibile mentre l'utente scrolla la tab Impostazioni. */
|
||||
.save-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 20px 0 32px;
|
||||
position: sticky;
|
||||
bottom: -24px;
|
||||
padding: 16px 32px;
|
||||
margin: 24px -32px -32px;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--divider);
|
||||
box-shadow: 0 -8px 16px rgba(0, 0, 0, 0.25);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
color: var(--green);
|
||||
color: var(--green-text);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
@@ -991,7 +1024,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
}
|
||||
|
||||
.toast.toast-error { border-color: var(--red); color: var(--red); }
|
||||
.toast.toast-success { border-color: var(--green); color: var(--green); }
|
||||
.toast.toast-success { border-color: var(--green); color: var(--green-text); }
|
||||
|
||||
/* ===============================
|
||||
SCROLLBARS (WebKit)
|
||||
@@ -999,11 +1032,11 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #404040;
|
||||
background: var(--text-3);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #606060; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-2); }
|
||||
|
||||
.log::-webkit-scrollbar-thumb { border-color: var(--bg-card-2); }
|
||||
.main::-webkit-scrollbar-thumb { border-color: var(--bg); }
|
||||
@@ -1538,3 +1571,140 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
BEATPORT
|
||||
=============================== */
|
||||
.beatport-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.beatport-header select.input {
|
||||
min-width: 260px;
|
||||
flex: 0 1 320px;
|
||||
}
|
||||
|
||||
.hint-inline {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.beatport-status {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.beatport-status.loading { color: var(--text-3); font-style: italic; }
|
||||
.beatport-status.error { color: var(--red); font-weight: 600; }
|
||||
.beatport-status.ok { color: var(--green-text); font-weight: 600; }
|
||||
|
||||
.beatport-output-info {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.beatport-output-info.warn {
|
||||
color: var(--amber-text);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.beatport-table-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.beatport-table-wrap {
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.beatport-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.beatport-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card-2);
|
||||
color: var(--text-3);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.beatport-table tbody td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.beatport-table tbody tr:hover {
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.beatport-table tbody tr.already-downloaded td {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.beatport-table tbody tr.already-downloaded .col-state {
|
||||
color: var(--green-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.beatport-table .col-check { width: 36px; }
|
||||
.beatport-table .col-cover { width: 52px; padding: 4px 8px; }
|
||||
.beatport-table .col-cover img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.beatport-table .col-pos { width: 40px; color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||
.beatport-table .col-dur { width: 70px; color: var(--text-2); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.beatport-table .col-state { width: 130px; font-size: 12px; }
|
||||
|
||||
.beatport-table input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--indigo, #6366F1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.beatport-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--divider);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.beatport-toolbar .counter {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.beatport-header { flex-direction: column; align-items: stretch; }
|
||||
.hint-inline { margin-left: 0; }
|
||||
.beatport-table .col-state { width: 110px; }
|
||||
}
|
||||
@@ -29,6 +29,18 @@
|
||||
<span class="nav-icon">🎬</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="beatport" data-feature="audio">
|
||||
<span class="nav-icon">🎧</span>
|
||||
<span>Beatport</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="spotify" data-feature="audio">
|
||||
<span class="nav-icon">🟢</span>
|
||||
<span>Spotify</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="youtube" data-feature="audio">
|
||||
<span class="nav-icon">▶</span>
|
||||
<span>YouTube</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
@@ -849,6 +861,183 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: BEATPORT ===== -->
|
||||
<section class="view" id="view-beatport">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">BEATPORT CHARTS</div>
|
||||
<h1 class="hero-title">Top 100 per genere</h1>
|
||||
<p class="hero-subtitle">Sfoglia la Top 100 di Beatport, seleziona i brani e scaricali via YouTube</p>
|
||||
</div>
|
||||
<div class="hero-deco">🎧</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Genere</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<select id="beatport-genre" class="input pill"></select>
|
||||
<button class="btn btn-primary pill" id="beatport-load-btn">
|
||||
<span class="ico">↓</span> Carica Top 100
|
||||
</button>
|
||||
<span class="hint-inline">Shift+click per bypassare la cache</span>
|
||||
</div>
|
||||
<div id="beatport-status" class="beatport-status"></div>
|
||||
<div id="beatport-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card">
|
||||
<div class="beatport-table-wrap">
|
||||
<table id="beatport-table" class="beatport-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo (Mix)</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-state">Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="beatport-toolbar" hidden>
|
||||
<span class="counter" id="beatport-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="beatport-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="beatport-stop-btn" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="beatport-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: SPOTIFY SEARCH ===== -->
|
||||
<section class="view" id="view-spotify">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">RICERCA SPOTIFY</div>
|
||||
<h1 class="hero-title">Cerca brani su Spotify</h1>
|
||||
<p class="hero-subtitle">Digita un artista, un titolo, o entrambi. Attiva "Solo artista" per scaricare tutta la discografia.</p>
|
||||
</div>
|
||||
<div class="hero-deco">🟢</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Ricerca</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<input type="text" id="spotify-query" class="input pill" placeholder="Es: Solomun, Hot Sauce, Kapuchon Hot Sauce…" style="flex:1; min-width:260px;" />
|
||||
<label style="display:flex; gap:6px; align-items:center; color:var(--text-2); font-size:13px;">
|
||||
<input type="checkbox" id="spotify-artist-mode" />
|
||||
<span>Solo artista (discografia)</span>
|
||||
</label>
|
||||
<button class="btn btn-primary pill" id="spotify-search-btn">
|
||||
<span class="ico">🔎</span> Cerca
|
||||
</button>
|
||||
</div>
|
||||
<div id="spotify-status" class="beatport-status"></div>
|
||||
<div id="spotify-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card" id="spotify-table-wrap" hidden>
|
||||
<div class="beatport-table-wrap">
|
||||
<table class="beatport-table" id="spotify-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo</th>
|
||||
<th>Album</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-state"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="spotify-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="spotify-toolbar">
|
||||
<span class="counter" id="spotify-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="spotify-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="spotify-stop-btn" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="spotify-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: YOUTUBE SEARCH ===== -->
|
||||
<section class="view" id="view-youtube">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">RICERCA YOUTUBE</div>
|
||||
<h1 class="hero-title">Cerca video su YouTube</h1>
|
||||
<p class="hero-subtitle">Perfetto per mix DJ, unreleased, bootleg e brani non presenti su Spotify.</p>
|
||||
</div>
|
||||
<div class="hero-deco">▶</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Ricerca</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<input type="text" id="youtube-query" class="input pill" placeholder="Es: Solomun Cocoricò 2024, Kapuchon Hot Sauce remix…" style="flex:1; min-width:260px;" />
|
||||
<button class="btn btn-primary pill" id="youtube-search-btn">
|
||||
<span class="ico">🔎</span> Cerca
|
||||
</button>
|
||||
</div>
|
||||
<div id="youtube-status" class="beatport-status"></div>
|
||||
<div id="youtube-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card" id="youtube-table-wrap" hidden>
|
||||
<div class="beatport-table-wrap">
|
||||
<table class="beatport-table" id="youtube-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Titolo video</th>
|
||||
<th>Canale</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-state"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="youtube-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="youtube-toolbar">
|
||||
<span class="counter" id="youtube-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="youtube-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="youtube-stop-btn" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="youtube-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+778
@@ -397,6 +397,8 @@ const logEls = {
|
||||
download: () => $("#dlLog"),
|
||||
upgrade: () => $("#upLog"),
|
||||
video: () => $("#videoLog"),
|
||||
spotify: () => $("#spotify-log"),
|
||||
youtube: () => $("#youtube-log"),
|
||||
};
|
||||
|
||||
function classifyLog(msg) {
|
||||
@@ -501,6 +503,13 @@ async function init() {
|
||||
$("#recPathDisplay").classList.remove("empty");
|
||||
}
|
||||
await refreshRecDevices();
|
||||
|
||||
// Beatport tab — popola generi, ripristina ultimo genere, aggancia handler
|
||||
await BeatportUI.init();
|
||||
|
||||
// Spotify + YouTube search tabs
|
||||
await SpotifyUI.init();
|
||||
await YoutubeUI.init();
|
||||
}
|
||||
|
||||
async function refreshRecDevices() {
|
||||
@@ -1170,6 +1179,775 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => {
|
||||
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica
|
||||
// (il download riusa start_tracks_download, quindi passa dagli stessi
|
||||
// canali "download:progress" / "download:done" / log view="download")
|
||||
// ============================================================
|
||||
const BeatportUI = (function () {
|
||||
let genres = [];
|
||||
let currentTracks = [];
|
||||
let existing = [];
|
||||
let currentGenreName = "";
|
||||
let currentGenreSlug = "";
|
||||
let downloading = false;
|
||||
|
||||
function fmtDur(sec) {
|
||||
const n = Number(sec) || 0;
|
||||
const m = Math.floor(n / 60);
|
||||
const s = Math.floor(n % 60);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function safeGenreFolder(name) {
|
||||
return String(name || "").replace(/[\/\\]/g, "_").trim();
|
||||
}
|
||||
|
||||
function populateSelect() {
|
||||
const sel = $("#beatport-genre");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = "";
|
||||
for (const g of genres) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g.slug;
|
||||
opt.textContent = g.name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function updateOutputInfo() {
|
||||
const info = $("#beatport-output-info");
|
||||
if (!info) return;
|
||||
const root = (state.config && state.config.output_dir) || "";
|
||||
if (!root) {
|
||||
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||
info.className = "beatport-output-info warn";
|
||||
return;
|
||||
}
|
||||
const folder = safeGenreFolder(currentGenreName);
|
||||
info.textContent = folder
|
||||
? `Destinazione: ${root}/Beatport/${folder}`
|
||||
: `Destinazione: ${root}/Beatport`;
|
||||
info.className = "beatport-output-info";
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#beatport-status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
async function loadChart(forceRefresh) {
|
||||
const sel = $("#beatport-genre");
|
||||
const slug = sel && sel.value;
|
||||
if (!slug) return;
|
||||
const genreName = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || slug;
|
||||
currentGenreSlug = slug;
|
||||
currentGenreName = genreName;
|
||||
|
||||
setStatus(forceRefresh ? "Ricarico Top 100 (bypass cache)…" : "Caricamento Top 100…", "loading");
|
||||
$("#beatport-table").hidden = true;
|
||||
$("#beatport-toolbar").hidden = true;
|
||||
$("#beatport-table").querySelector("tbody").innerHTML = "";
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.beatport_fetch_chart(slug, !!forceRefresh);
|
||||
} catch (e) {
|
||||
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const code = res && res.error;
|
||||
let msg = (res && res.message) || "Errore sconosciuto";
|
||||
if (code === "invalid_genre") msg = "Genere non valido";
|
||||
else if (code === "unreachable") msg = "Beatport non raggiungibile — riprova più tardi";
|
||||
else if (code === "parse") msg = "Impossibile leggere i dati (schema pagina cambiato?)";
|
||||
setStatus(msg, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
currentTracks = res.tracks || [];
|
||||
try {
|
||||
existing = await window.pywebview.api.beatport_check_existing(currentTracks, currentGenreName);
|
||||
} catch (_e) {
|
||||
existing = currentTracks.map(() => false);
|
||||
}
|
||||
setStatus(`${currentTracks.length} brani caricati`, "ok");
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const table = $("#beatport-table");
|
||||
const tbody = table.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
currentTracks.forEach((t, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
if (existing[i]) tr.classList.add("already-downloaded");
|
||||
|
||||
const tdCheck = document.createElement("td");
|
||||
tdCheck.className = "col-check";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.dataset.idx = String(i);
|
||||
cb.checked = !existing[i];
|
||||
cb.addEventListener("change", updateSelectionCount);
|
||||
tdCheck.appendChild(cb);
|
||||
tr.appendChild(tdCheck);
|
||||
|
||||
const tdCover = document.createElement("td");
|
||||
tdCover.className = "col-cover";
|
||||
if (t.image_url) {
|
||||
const img = document.createElement("img");
|
||||
img.src = t.image_url;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
img.referrerPolicy = "no-referrer";
|
||||
img.onerror = () => { img.style.visibility = "hidden"; };
|
||||
tdCover.appendChild(img);
|
||||
}
|
||||
tr.appendChild(tdCover);
|
||||
|
||||
const tdPos = document.createElement("td");
|
||||
tdPos.className = "col-pos";
|
||||
tdPos.textContent = String(t.position || (i + 1));
|
||||
tr.appendChild(tdPos);
|
||||
|
||||
const tdArtist = document.createElement("td");
|
||||
tdArtist.textContent = t.artists || "";
|
||||
tr.appendChild(tdArtist);
|
||||
|
||||
const tdTitle = document.createElement("td");
|
||||
tdTitle.textContent = t.mix ? `${t.title} (${t.mix})` : (t.title || "");
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
const tdDur = document.createElement("td");
|
||||
tdDur.className = "col-dur";
|
||||
tdDur.textContent = fmtDur(t.duration_sec);
|
||||
tr.appendChild(tdDur);
|
||||
|
||||
const tdState = document.createElement("td");
|
||||
tdState.className = "col-state";
|
||||
if (existing[i]) tdState.textContent = "✓ già scaricato";
|
||||
tr.appendChild(tdState);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.hidden = false;
|
||||
$("#beatport-toolbar").hidden = false;
|
||||
updateOutputInfo();
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
const boxes = $$("#beatport-table tbody input[type=checkbox]");
|
||||
const total = boxes.length;
|
||||
const checked = boxes.filter((b) => b.checked).length;
|
||||
$("#beatport-selected-count").textContent = `${checked}/${total} selezionati`;
|
||||
const dlBtn = $("#beatport-download-btn");
|
||||
if (dlBtn) dlBtn.disabled = downloading || checked === 0;
|
||||
|
||||
const master = $("#beatport-select-all");
|
||||
if (master) {
|
||||
if (total === 0) { master.checked = false; master.indeterminate = false; }
|
||||
else if (checked === 0) { master.checked = false; master.indeterminate = false; }
|
||||
else if (checked === total) { master.checked = true; master.indeterminate = false; }
|
||||
else { master.checked = false; master.indeterminate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll(checked) {
|
||||
$$("#beatport-table tbody input[type=checkbox]").forEach((b) => { b.checked = checked; });
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function appendBeatportLog(msg) {
|
||||
const el = $("#beatport-log");
|
||||
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;
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const boxes = $$("#beatport-table tbody input[type=checkbox]");
|
||||
const selected = [];
|
||||
boxes.forEach((b) => {
|
||||
if (b.checked) {
|
||||
const idx = parseInt(b.dataset.idx, 10);
|
||||
if (!isNaN(idx) && currentTracks[idx]) selected.push(currentTracks[idx]);
|
||||
}
|
||||
});
|
||||
if (!selected.length) {
|
||||
toast("Seleziona almeno un brano", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.config || !state.config.output_dir) {
|
||||
toast("Imposta la cartella output in Impostazioni", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
downloading = true;
|
||||
$("#beatport-download-btn").disabled = true;
|
||||
$("#beatport-stop-btn").hidden = false;
|
||||
$("#beatport-stop-btn").disabled = false;
|
||||
$("#beatport-log").innerHTML = "";
|
||||
appendBeatportLog(`[INFO] Avvio download di ${selected.length} brani da ${currentGenreName}…`);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.beatport_download_selected(selected, currentGenreName);
|
||||
} catch (e) {
|
||||
appendBeatportLog("[ERRORE] " + ((e && e.message) || e));
|
||||
finishDownload();
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||
appendBeatportLog("[ERRORE] " + errMsg);
|
||||
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||
finishDownload();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDownload() {
|
||||
try {
|
||||
await window.pywebview.api.stop_download();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function finishDownload() {
|
||||
downloading = false;
|
||||
$("#beatport-stop-btn").hidden = true;
|
||||
$("#beatport-stop-btn").disabled = true;
|
||||
updateSelectionCount();
|
||||
// Re-check existing per aggiornare i badge "già scaricato"
|
||||
if (currentTracks.length) {
|
||||
window.pywebview.api.beatport_check_existing(currentTracks, currentGenreName)
|
||||
.then((res) => { existing = res || existing; renderTable(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Popola dropdown generi
|
||||
try {
|
||||
genres = await window.pywebview.api.beatport_genres();
|
||||
} catch (e) {
|
||||
console.error("Beatport: impossibile caricare i generi", e);
|
||||
genres = [];
|
||||
}
|
||||
populateSelect();
|
||||
|
||||
// Ripristina l'ultimo genere selezionato (state.config gia' caricato)
|
||||
const last = (state.config && state.config.beatport_last_genre) || "";
|
||||
const sel = $("#beatport-genre");
|
||||
if (sel && last && genres.some((g) => g.slug === last)) {
|
||||
sel.value = last;
|
||||
}
|
||||
updateOutputInfo();
|
||||
|
||||
// Bind eventi
|
||||
$("#beatport-load-btn").addEventListener("click", (e) => loadChart(e.shiftKey));
|
||||
$("#beatport-download-btn").addEventListener("click", startDownload);
|
||||
$("#beatport-stop-btn").addEventListener("click", stopDownload);
|
||||
$("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked));
|
||||
|
||||
// Aggancia agli event listener esistenti del canale "download" senza rompere
|
||||
// il comportamento della tab Scarica (wrapping additivo).
|
||||
const origProgress = bridgeHandlers["download:progress"];
|
||||
bridgeHandlers["download:progress"] = (p) => {
|
||||
if (origProgress) origProgress(p);
|
||||
// I contatori del tab Scarica sono aggiornati dall'handler originale;
|
||||
// qui potremmo aggiungere un contatore Beatport, ma i log sono sufficienti.
|
||||
};
|
||||
const origDone = bridgeHandlers["download:done"];
|
||||
bridgeHandlers["download:done"] = (p) => {
|
||||
if (origDone) origDone(p);
|
||||
if (downloading) finishDownload();
|
||||
};
|
||||
const origLog = bridgeHandlers["log"];
|
||||
bridgeHandlers["log"] = ({ view, msg }) => {
|
||||
if (origLog) origLog({ view, msg });
|
||||
if (downloading && view === "download") appendBeatportLog(msg);
|
||||
};
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// =====================================================================
|
||||
// Spotify search
|
||||
// =====================================================================
|
||||
const SpotifyUI = (() => {
|
||||
// NOTE: local module state — named `mstate` to NOT shadow the outer
|
||||
// module-level `state` (which holds `state.config`).
|
||||
const mstate = { tracks: [], existing: [], downloading: false };
|
||||
|
||||
function _escape(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s == null ? "" : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function _fmtDuration(sec) {
|
||||
const n = Math.max(0, Math.floor(Number(sec) || 0));
|
||||
const m = Math.floor(n / 60);
|
||||
const s = n % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
const boxes = $$("#spotify-tbody input[type=checkbox]");
|
||||
const total = boxes.length;
|
||||
const selected = boxes.filter((b) => b.checked).length;
|
||||
$("#spotify-selected-count").textContent = `${selected}/${total} selezionati`;
|
||||
const btn = $("#spotify-download-btn");
|
||||
if (btn) {
|
||||
btn.disabled = mstate.downloading || selected === 0;
|
||||
btn.innerHTML = `<span class="ico">▶</span> Scarica selezionati${selected ? ` (${selected})` : ""}`;
|
||||
}
|
||||
const sa = $("#spotify-select-all");
|
||||
if (sa) {
|
||||
if (total === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||
else if (selected === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||
else if (selected === total) { sa.checked = true; sa.indeterminate = false; }
|
||||
else { sa.checked = false; sa.indeterminate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = $("#spotify-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
mstate.tracks.forEach((t, i) => {
|
||||
const already = !!mstate.existing[i];
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.idx = String(i);
|
||||
if (already) tr.classList.add("already-downloaded");
|
||||
const coverImg = t.image_url
|
||||
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||
: "";
|
||||
tr.innerHTML = `
|
||||
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
||||
<td class="col-cover">${coverImg}</td>
|
||||
<td class="col-pos">${i + 1}</td>
|
||||
<td>${_escape(t.artists)}</td>
|
||||
<td>${_escape(t.name)}</td>
|
||||
<td>${_escape(t.album)}</td>
|
||||
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||
`;
|
||||
const cb = tr.querySelector("input[type=checkbox]");
|
||||
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#spotify-status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
function updateOutputInfo() {
|
||||
const info = $("#spotify-output-info");
|
||||
if (!info) return;
|
||||
const root = (state.config && state.config.output_dir) || "";
|
||||
if (!root) {
|
||||
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||
info.className = "beatport-output-info warn";
|
||||
} else {
|
||||
info.textContent = `Destinazione: ${root}/Spotify`;
|
||||
info.className = "beatport-output-info";
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const q = ($("#spotify-query").value || "").trim();
|
||||
if (!q) return;
|
||||
const artistMode = $("#spotify-artist-mode").checked;
|
||||
const wrap = $("#spotify-table-wrap");
|
||||
|
||||
setStatus("Ricerca in corso…", "loading");
|
||||
if (wrap) wrap.hidden = true;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.spotify_search(q, artistMode);
|
||||
} catch (e) {
|
||||
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errKey = res && res.error;
|
||||
const messages = {
|
||||
empty_query: "Inserisci una query di ricerca.",
|
||||
no_creds: "Credenziali Spotify mancanti. Vai su Impostazioni.",
|
||||
auth: "Impossibile autenticarsi con Spotify. Verifica le credenziali.",
|
||||
};
|
||||
setStatus(messages[errKey] || (res && res.message) || "Errore sconosciuto", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
mstate.tracks = res.tracks || [];
|
||||
if (mstate.tracks.length === 0) {
|
||||
setStatus(`Nessun brano trovato per "${q}".`, "");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mstate.existing = await window.pywebview.api.spotify_check_existing(mstate.tracks);
|
||||
} catch (_e) {
|
||||
mstate.existing = mstate.tracks.map(() => false);
|
||||
}
|
||||
|
||||
setStatus(`${mstate.tracks.length} risultati`, "ok");
|
||||
updateOutputInfo();
|
||||
renderTable();
|
||||
if (wrap) wrap.hidden = false;
|
||||
}
|
||||
|
||||
function appendSpotifyLog(msg) {
|
||||
const el = $("#spotify-log");
|
||||
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;
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const boxes = $$("#spotify-tbody input[type=checkbox]");
|
||||
const selected = [];
|
||||
boxes.forEach((b) => {
|
||||
if (b.checked) {
|
||||
const idx = parseInt(b.dataset.idx, 10);
|
||||
if (!isNaN(idx) && mstate.tracks[idx]) selected.push(mstate.tracks[idx]);
|
||||
}
|
||||
});
|
||||
if (!selected.length) {
|
||||
toast("Seleziona almeno un brano", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.config || !state.config.output_dir) {
|
||||
toast("Imposta la cartella output in Impostazioni", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
mstate.downloading = true;
|
||||
$("#spotify-download-btn").disabled = true;
|
||||
$("#spotify-stop-btn").hidden = false;
|
||||
$("#spotify-stop-btn").disabled = false;
|
||||
$("#spotify-log").innerHTML = "";
|
||||
appendSpotifyLog(`[INFO] Avvio download di ${selected.length} brani…`);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.spotify_search_download(selected);
|
||||
} catch (e) {
|
||||
appendSpotifyLog("[ERRORE] " + ((e && e.message) || e));
|
||||
finishDownload();
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||
appendSpotifyLog("[ERRORE] " + errMsg);
|
||||
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||
finishDownload();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDownload() {
|
||||
try { await window.pywebview.api.stop_download(); } catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function finishDownload() {
|
||||
mstate.downloading = false;
|
||||
$("#spotify-stop-btn").hidden = true;
|
||||
$("#spotify-stop-btn").disabled = true;
|
||||
updateSelectionCount();
|
||||
if (mstate.tracks.length) {
|
||||
window.pywebview.api.spotify_check_existing(mstate.tracks)
|
||||
.then((r) => { mstate.existing = r || mstate.existing; renderTable(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Pre-compila da config
|
||||
try {
|
||||
const q = (state.config && state.config.spotify_search_last_query) || "";
|
||||
const am = !!(state.config && state.config.spotify_search_artist_mode);
|
||||
const qi = $("#spotify-query");
|
||||
if (qi && q) qi.value = q;
|
||||
const ac = $("#spotify-artist-mode");
|
||||
if (ac) ac.checked = am;
|
||||
} catch (_e) { /* ignora */ }
|
||||
|
||||
updateOutputInfo();
|
||||
|
||||
$("#spotify-search-btn").addEventListener("click", doSearch);
|
||||
$("#spotify-query").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
|
||||
$("#spotify-select-all").addEventListener("change", (e) => {
|
||||
$$("#spotify-tbody input[type=checkbox]").forEach((b) => { b.checked = e.target.checked; });
|
||||
updateSelectionCount();
|
||||
});
|
||||
$("#spotify-download-btn").addEventListener("click", startDownload);
|
||||
$("#spotify-stop-btn").addEventListener("click", stopDownload);
|
||||
|
||||
// Wrap bridge handlers additivamente
|
||||
const origLog = bridgeHandlers["log"];
|
||||
bridgeHandlers["log"] = (payload) => {
|
||||
if (origLog) origLog(payload);
|
||||
if (mstate.downloading && payload && payload.view === "download") {
|
||||
appendSpotifyLog(payload.msg);
|
||||
}
|
||||
};
|
||||
const origDone = bridgeHandlers["download:done"];
|
||||
bridgeHandlers["download:done"] = (payload) => {
|
||||
if (origDone) origDone(payload);
|
||||
if (mstate.downloading) finishDownload();
|
||||
};
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// =====================================================================
|
||||
// YouTube search
|
||||
// =====================================================================
|
||||
const YoutubeUI = (() => {
|
||||
const mstate = { tracks: [], existing: [], downloading: false };
|
||||
|
||||
function _escape(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s == null ? "" : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
function _fmtDuration(sec) {
|
||||
const n = Math.max(0, Math.floor(Number(sec) || 0));
|
||||
const m = Math.floor(n / 60);
|
||||
const s = n % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
const boxes = $$("#youtube-tbody input[type=checkbox]");
|
||||
const total = boxes.length;
|
||||
const selected = boxes.filter((b) => b.checked).length;
|
||||
$("#youtube-selected-count").textContent = `${selected}/${total} selezionati`;
|
||||
const btn = $("#youtube-download-btn");
|
||||
if (btn) {
|
||||
btn.disabled = mstate.downloading || selected === 0;
|
||||
btn.innerHTML = `<span class="ico">▶</span> Scarica selezionati${selected ? ` (${selected})` : ""}`;
|
||||
}
|
||||
const sa = $("#youtube-select-all");
|
||||
if (sa) {
|
||||
if (total === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||
else if (selected === 0) { sa.checked = false; sa.indeterminate = false; }
|
||||
else if (selected === total) { sa.checked = true; sa.indeterminate = false; }
|
||||
else { sa.checked = false; sa.indeterminate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = $("#youtube-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
mstate.tracks.forEach((t, i) => {
|
||||
const already = !!mstate.existing[i];
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.idx = String(i);
|
||||
if (already) tr.classList.add("already-downloaded");
|
||||
const coverImg = t.image_url
|
||||
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||
: "";
|
||||
tr.innerHTML = `
|
||||
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
||||
<td class="col-cover">${coverImg}</td>
|
||||
<td class="col-pos">${i + 1}</td>
|
||||
<td>${_escape(t.title)}</td>
|
||||
<td>${_escape(t.channel)}</td>
|
||||
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||
`;
|
||||
const cb = tr.querySelector("input[type=checkbox]");
|
||||
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#youtube-status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
function updateOutputInfo() {
|
||||
const info = $("#youtube-output-info");
|
||||
if (!info) return;
|
||||
const root = (state.config && state.config.output_dir) || "";
|
||||
if (!root) {
|
||||
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||
info.className = "beatport-output-info warn";
|
||||
} else {
|
||||
info.textContent = `Destinazione: ${root}/YouTube`;
|
||||
info.className = "beatport-output-info";
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const q = ($("#youtube-query").value || "").trim();
|
||||
if (!q) return;
|
||||
const wrap = $("#youtube-table-wrap");
|
||||
|
||||
setStatus("Ricerca su YouTube in corso…", "loading");
|
||||
if (wrap) wrap.hidden = true;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.youtube_search(q);
|
||||
} catch (e) {
|
||||
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
setStatus((res && res.message) || "Errore sconosciuto", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
mstate.tracks = res.tracks || [];
|
||||
if (mstate.tracks.length === 0) {
|
||||
setStatus(`Nessun video trovato per "${q}".`, "");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mstate.existing = await window.pywebview.api.youtube_check_existing(mstate.tracks);
|
||||
} catch (_e) {
|
||||
mstate.existing = mstate.tracks.map(() => false);
|
||||
}
|
||||
|
||||
setStatus(`${mstate.tracks.length} risultati`, "ok");
|
||||
updateOutputInfo();
|
||||
renderTable();
|
||||
if (wrap) wrap.hidden = false;
|
||||
}
|
||||
|
||||
function appendYoutubeLog(msg) {
|
||||
const el = $("#youtube-log");
|
||||
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;
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const boxes = $$("#youtube-tbody input[type=checkbox]");
|
||||
const selected = [];
|
||||
boxes.forEach((b) => {
|
||||
if (b.checked) {
|
||||
const idx = parseInt(b.dataset.idx, 10);
|
||||
if (!isNaN(idx) && mstate.tracks[idx]) selected.push(mstate.tracks[idx]);
|
||||
}
|
||||
});
|
||||
if (!selected.length) {
|
||||
toast("Seleziona almeno un video", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.config || !state.config.output_dir) {
|
||||
toast("Imposta la cartella output in Impostazioni", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
mstate.downloading = true;
|
||||
$("#youtube-download-btn").disabled = true;
|
||||
$("#youtube-stop-btn").hidden = false;
|
||||
$("#youtube-stop-btn").disabled = false;
|
||||
$("#youtube-log").innerHTML = "";
|
||||
appendYoutubeLog(`[INFO] Avvio download di ${selected.length} video…`);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.youtube_search_download(selected);
|
||||
} catch (e) {
|
||||
appendYoutubeLog("[ERRORE] " + ((e && e.message) || e));
|
||||
finishDownload();
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||
appendYoutubeLog("[ERRORE] " + errMsg);
|
||||
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||
finishDownload();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDownload() {
|
||||
try { await window.pywebview.api.stop_download(); } catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function finishDownload() {
|
||||
mstate.downloading = false;
|
||||
$("#youtube-stop-btn").hidden = true;
|
||||
$("#youtube-stop-btn").disabled = true;
|
||||
updateSelectionCount();
|
||||
if (mstate.tracks.length) {
|
||||
window.pywebview.api.youtube_check_existing(mstate.tracks)
|
||||
.then((r) => { mstate.existing = r || mstate.existing; renderTable(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const q = (state.config && state.config.youtube_search_last_query) || "";
|
||||
const qi = $("#youtube-query");
|
||||
if (qi && q) qi.value = q;
|
||||
} catch (_e) { /* ignora */ }
|
||||
|
||||
updateOutputInfo();
|
||||
|
||||
$("#youtube-search-btn").addEventListener("click", doSearch);
|
||||
$("#youtube-query").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); });
|
||||
$("#youtube-select-all").addEventListener("change", (e) => {
|
||||
$$("#youtube-tbody input[type=checkbox]").forEach((b) => { b.checked = e.target.checked; });
|
||||
updateSelectionCount();
|
||||
});
|
||||
$("#youtube-download-btn").addEventListener("click", startDownload);
|
||||
$("#youtube-stop-btn").addEventListener("click", stopDownload);
|
||||
|
||||
const origLog = bridgeHandlers["log"];
|
||||
bridgeHandlers["log"] = (payload) => {
|
||||
if (origLog) origLog(payload);
|
||||
if (mstate.downloading && payload && payload.view === "download") {
|
||||
appendYoutubeLog(payload.msg);
|
||||
}
|
||||
};
|
||||
const origDone = bridgeHandlers["download:done"];
|
||||
bridgeHandlers["download:done"] = (payload) => {
|
||||
if (origDone) origDone(payload);
|
||||
if (mstate.downloading) finishDownload();
|
||||
};
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// Boot
|
||||
// ============================================================
|
||||
|
||||
Reference in new issue
Block a user