Compare commits

...
46 Commits
Author SHA1 Message Date
luciano 72e700c85d config: bump VERSION v1.8.6 2026-07-21 17:51:22 +02:00
luciano a774906814 ui: ConvertUI (file picker, opzioni, batch conversione) 2026-07-21 17:48:31 +02:00
luciano 9f41f9d302 ui: markup tab Converti 2026-07-21 17:47:16 +02:00
luciano b0cd046375 bridge: 4 metodi Api per convertitore WAV -> MP3 2026-07-21 17:44:59 +02:00
luciano 5fc9f8ac2b converter: modulo ffmpeg WAV -> MP3 con VBR/CBR + stop 2026-07-21 17:44:36 +02:00
luciano c4e6d6e322 fix: bundle certifi CA per SSL su Windows/macOS + bump v1.8.5
- requirements.txt: certifi come dep esplicita
- main.py: setta SSL_CERT_FILE/REQUESTS_CA_BUNDLE su certifi.where() al boot
- build_windows.py + build_macos.py: --collect-data certifi nel PyInstaller

Bugfix: senza certifi bundlato, le chiamate HTTPS dal build Windows
falliscono con CERTIFICATE_VERIFY_FAILED (attivazione licenza impossibile).
2026-07-17 16:26:16 +02:00
luciano c7dfe95890 config: bump VERSION v1.8.4 2026-07-15 13:04:06 +02:00
luciano 25b646d754 bridge: metadata pipeline per tag ID3 su download search 2026-07-15 13:00:19 +02:00
luciano 73b4480890 downloader: param output_filenames + post_download_callback 2026-07-15 12:56:56 +02:00
luciano d53b8f20ed spotify: get_artist_genres + enrich_from_youtube_title + cover_url_large 2026-07-15 12:54:59 +02:00
luciano 0f7cd24703 tagger: modulo mutagen per ID3 tag + cover art da URL 2026-07-15 12:54:26 +02:00
luciano f1437e8e24 config: bump VERSION v1.8.3 2026-07-15 11:46:50 +02:00
luciano acbd9c0b1f ui: hero sticky in cima al pannello scrollabile di ogni tab 2026-07-15 11:45:43 +02:00
luciano f226c3fa8e ui: cover art in Beatport, Spotify e YouTube search
- core/beatport.py: BeatportTrack.image_url estratto da image.dynamic_uri (95x95)
- core/spotify_client.py: _track_to_dict include image_url (Spotify torna 3 taglie,
  prendo la ~64px). search_artist_discography inietta album.images anche negli
  album-tracks per la cover
- core/youtube_search.py: image_url = i.ytimg.com/vi/<id>/mqdefault.jpg
- webui/index.html: nuova <th class="col-cover"></th> nelle 3 tabelle
- webui/js/app.js: 3 renderTable inseriscono <td class="col-cover"><img>
  con loading=lazy, referrerpolicy=no-referrer e onerror fallback
- webui/css/style.css: .beatport-table .col-cover img { 40x40, border-radius 4px,
  object-fit: cover, bg fallback var(--bg-input) }
2026-07-15 11:45:28 +02:00
luciano 85f7ec0993 config: bump VERSION v1.8.2 2026-07-14 22:15:02 +02:00
luciano 9dbdd74b2f ui: fix light mode — colori brand come testo + hover/focus adattivi
- Introdotte varianti --*-text (green/blue/purple/pink/amber/indigo/rec-red)
  scure in light mode per contrasto AA su bg bianco
- Convertite le rule che usano brand colors come foreground (log, badge,
  quota, footer, percent, info-row, update-status, save-status, toast,
  beatport-status, colonna "già scaricato")
- Nav-item hover da #1A1A1A hardcoded → var(--bg-card-2)
- Input focus da #333 hardcoded → var(--bg-input-hover)
- Scrollbar thumb da #404040/#606060 hardcoded → var(--text-3)/var(--text-2)
- .hint code bg da rgba(255,255,255,0.08) → var(--bg-input)
- Table hover da rgba(99,102,241,0.06) → var(--bg-input) (visibile in light)
- .log .l-warn da #FDCB6E hardcoded → var(--amber-text)
2026-07-14 22:12:50 +02:00
luciano 8c73651312 Merge feat/music-search: MusicTools v1.8.1
Nuove tab Spotify e YouTube per cercare brani per titolo/artista.
Spotify con toggle "Solo artista" per scaricare tutta la discografia.
YouTube per mix DJ, unreleased, bootleg via yt-dlp ytsearch.

40 test verdi. Testato end-to-end in dev su macOS.
2026-07-14 17:34:22 +02:00
luciano 1fa49fabc4 ui: SpotifyUI + YoutubeUI (search, table, download, log) 2026-07-14 17:31:48 +02:00
luciano 83c47df29c ui: markup tab Spotify + YouTube search 2026-07-14 17:31:41 +02:00
luciano 9ea14eb81c bridge: 6 metodi Api per music search (Spotify + YouTube) 2026-07-14 17:25:58 +02:00
luciano 91cfc173d4 config: v1.8.1 + 3 campi DEFAULTS per music search 2026-07-14 17:23:49 +02:00
luciano 67e48e54f7 downloader+bridge: supporto download da URL diretti (YouTube search flow)
Aggiunge download_urls() in core/downloader.py che scarica direttamente da
URL YouTube gia noti (bypassa lo step di ricerca). Usa gli stessi flag di
download_playlist (mp3, embed thumbnail/metadata, output template, cookies,
ffmpeg location) e il parsing progress via stdout.

In api/bridge.py aggiunge start_urls_download() e _urls_worker() speculari a
start_tracks_download/_tracks_worker. Consumati dal tab "YouTube Search"
dopo che l'utente ha selezionato i risultati.
2026-07-14 17:22:46 +02:00
luciano 0949df031a youtube: search_youtube via yt-dlp ytsearchN + parse JSON 2026-07-14 17:18:54 +02:00
luciano 2771fa8075 spotify: search_artist_discography con dedupe top-tracks+album 2026-07-14 17:16:19 +02:00
luciano 252d76b907 spotify: search_tracks() free-form con limit configurabile 2026-07-14 17:14:02 +02:00
luciano 58d4896ffe plan: implementation Music Search (Spotify + YouTube) v1.8.1 2026-07-14 17:11:13 +02:00
luciano 91aa1bd6a3 spec: feature Music Search (Spotify + YouTube tabs) v1.8.1 2026-07-14 17:05:56 +02:00
luciano 48a6e15c7a Merge feat/beatport-charts: MusicTools v1.8.0
Nuova tab Beatport per scaricare le Top 100 per genere direttamente
dal sito Beatport (bypass Cloudflare via curl_cffi + TLS impersonation).
Riusa il downloader esistente YouTube via yt-dlp.

25 unit test verdi. Testato end-to-end in dev su macOS.
2026-07-14 16:48:36 +02:00
luciano f850e86a88 docs: implementation plan Beatport charts 2026-07-14 16:42:27 +02:00
luciano 6bff24ff7d beatport: fix check_existing/download path mismatch
start_tracks_download sanitizza gli slash del subfolder (/ -> _), quindi
i file finivano in Beatport_<genre>/ ma check_existing cercava in
Beatport/<genre>/. Estratto helper _beatport_output_dir per allinearli.
2026-07-14 16:41:38 +02:00
luciano faf74dfb11 ui: stili tab Beatport 2026-07-14 16:39:36 +02:00
luciano d2f3ebe361 ui: JS BeatportUI (init, loadChart, download, log) 2026-07-14 16:39:04 +02:00
luciano 33c6bc7703 ui: markup tab Beatport 2026-07-14 16:37:44 +02:00
luciano be4f7e1024 bridge: 4 metodi Api per feature Beatport (fetch + download riusa start_tracks_download) 2026-07-14 16:30:49 +02:00
luciano 1c52d52986 config: v1.8.0 + beatport_last_genre nei DEFAULTS 2026-07-14 16:27:22 +02:00
luciano cc5716e87d spotify: aggiunto search_track() per feature Beatport 2026-07-14 16:26:25 +02:00
luciano 3f31e240be beatport: fetch_top100 con curl_cffi + retry + cache 15min 2026-07-14 16:24:43 +02:00
luciano 13c8e60712 beatport: parse JSON → list[BeatportTrack] 2026-07-14 16:22:21 +02:00
luciano f1e57905c1 beatport: estrazione __NEXT_DATA__ + eccezioni tipizzate 2026-07-14 16:20:21 +02:00
luciano 4221a5992b beatport: BeatportTrack dataclass con display + spotify_query 2026-07-14 16:18:38 +02:00
luciano 70cdfe8983 beatport: GENRES + list_genres() con test 2026-07-14 16:17:09 +02:00
luciano e1aaa83fe9 beatport: fixture HTML + curl_cffi + script refresh generi 2026-07-14 16:14:55 +02:00
luciano c037da4901 test: setup pytest infra per feature Beatport 2026-07-14 16:03:33 +02:00
luciano 21ef3a5699 spec: feature Beatport charts (v1.8.0 target)
Design approvato via brainstorming: nuova tab per caricare le Top 100
Beatport per genere e scaricare i brani selezionati riusando il flusso
Spotify search -> yt-dlp. Approccio dati: parse __NEXT_DATA__ JSON dalle
pagine Beatport, no scraping fragile, no API interna.
2026-07-14 15:50:48 +02:00
luciano e4bfd76a79 server: script gen-license per generare licenze manuali da locale
- server/scripts/gen-license.mjs: usa generateLicenseKey e getPlan ufficiali,
  INSERT nel DB con source='manual', ritorna JSON con la chiave
- server/scripts/gen-license.sh: wrapper locale che rsync-a lo script sul
  server e lo esegue via ssh (annual di default, oppure basic/pro/premium)

Sostituisce il flusso ad-hoc di scp'are /tmp/genlic_*.mjs a ogni generazione.
2026-07-13 10:33:25 +02:00
luzadev e2ac1ca275 Landing: rimossi piani e bottoni di acquisto, app non ancora in vendita
- Sezione PRICING: tolte le 4 card (Basic/Pro/Premium/Annual) con
  rispettivi bottoni Lemon Squeezy. Sostituita con un riquadro
  "In arrivo / scrivici a info@djluza.com".
- Nav, hero CTA, CTA finale: nessun "Vedi i piani" / "Compra ora" /
  "Acquista ora". Sostituiti con CTA informative (Funzioni, Contatti).
- Meta description, og:description, hero eyebrow: rimossi i prezzi.
- FAQ: rimosse le voci che dettagliavano piani, limiti giornalieri,
  upgrade, scadenza Annual, metodi di pagamento Lemon Squeezy.
- Step 1 di "Come funziona": "Compra" -> "Ottieni" con disclaimer.
- Rimosso lo script lemon.js dal head (nessun bottone lo usa piu').

Link "Hai gia' una licenza? Scarica l'app" e pagina /download
restano attivi per i clienti gia' attivati. Backend immutato.
2026-06-15 10:33:35 +02:00
31 changed files with 8622 additions and 165 deletions

No files matched your search

+710 -4
View File
@@ -8,9 +8,14 @@ import re
import threading
import time
import webbrowser
from typing import Any, Optional
from dataclasses import asdict
from pathlib import Path
from typing import Any, Callable, 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,
@@ -27,6 +32,7 @@ from core.spotify_client import (
SpotifyAuthRequired,
)
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
from core import tagger
from core.recorder import (
list_input_devices,
start_recording,
@@ -511,13 +517,20 @@ class Api:
def start_tracks_download(self, payload: dict) -> dict:
"""Avvia il download di una tracklist gia parsata
(lista di {name, artist}). Se 'subfolder' e presente,
scarica in output_dir/subfolder."""
scarica in output_dir/subfolder.
`payload['metadata']` opzionale: lista parallela a `tracks` con dict
per il tagging ID3 (title, artist, album, date, genre, tracknumber,
cover_url). Se presente, i file MP3 vengono nominati come
"Artista - Titolo.mp3" e taggati post-download.
"""
if self._any_job_running():
return {"ok": False, "error": "Un download gia in corso"}
tracks = payload.get("tracks") or []
output_dir = (payload.get("output_dir") or "").strip()
subfolder = (payload.get("subfolder") or "").strip()
metadata_list = payload.get("metadata") or None
if not tracks:
return {"ok": False, "error": "Nessuna traccia fornita"}
if not output_dir:
@@ -536,6 +549,49 @@ class Api:
self._download_thread = threading.Thread(
target=self._tracks_worker,
args=(list(tracks), output_dir),
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
daemon=True,
)
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'.
`payload['metadata']` opzionale: lista parallela a `urls` con dict
per il tagging ID3 (title, artist, album, date, genre, tracknumber,
cover_url). Se presente, i file MP3 vengono nominati come
"Artista - Titolo.mp3" e taggati post-download.
"""
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()
metadata_list = payload.get("metadata") or None
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),
kwargs={"metadata_list": list(metadata_list) if metadata_list else None},
daemon=True,
)
self._download_thread.start()
@@ -546,7 +602,8 @@ class Api:
self._log("download", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _tracks_worker(self, tracks: list, output_dir: str) -> None:
def _tracks_worker(self, tracks: list, output_dir: str,
metadata_list: Optional[list] = None) -> None:
reset_download_stop()
cfg = load_config()
bitrate = cfg.get("bitrate", "320K")
@@ -556,6 +613,31 @@ class Api:
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
self._log(view, f"[INFO] Destinazione: {output_dir}")
# Pipeline tagging: se metadata_list fornito, calcola i filename
# ("Artista - Titolo") e prepara callback per scrivere ID3 tag
# + cover art dopo ogni download riuscito.
output_filenames: Optional[list] = None
post_cb: Optional[Callable] = None
if metadata_list:
output_filenames = []
for md in metadata_list:
md = md or {}
stem = tagger.build_filename_stem(
md.get("artist", ""),
md.get("title", ""),
)
output_filenames.append(stem)
def post_cb(idx: int, filepath: str,
_md_list=metadata_list) -> None:
if idx < 0 or idx >= len(_md_list):
return
md = _md_list[idx] or {}
try:
tagger.write_tags(filepath, md)
except Exception:
pass # tagging best-effort, non blocca il download
_last = [0.0]
_THROTTLE = 0.10
@@ -595,7 +677,86 @@ class Api:
self._emit("download:progress", payload_evt)
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb)
download_playlist(
tracks, output_dir, bitrate, cookies_path, progress_cb,
output_filenames=output_filenames,
post_download_callback=post_cb,
)
self._emit("download:done", {"ok": True})
def _urls_worker(self, urls: list, titles: list, output_dir: str,
metadata_list: Optional[list] = None) -> 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}")
# Stessa pipeline tagging di _tracks_worker
output_filenames: Optional[list] = None
post_cb: Optional[Callable] = None
if metadata_list:
output_filenames = []
for md in metadata_list:
md = md or {}
stem = tagger.build_filename_stem(
md.get("artist", ""),
md.get("title", ""),
)
output_filenames.append(stem)
def post_cb(idx: int, filepath: str,
_md_list=metadata_list) -> None:
if idx < 0 or idx >= len(_md_list):
return
md = _md_list[idx] or {}
try:
tagger.write_tags(filepath, md)
except Exception:
pass # tagging best-effort, non blocca il download
_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,
output_filenames=output_filenames,
post_download_callback=post_cb,
)
self._emit("download:done", {"ok": True})
def _download_worker(self, urls: list, output_dir: str) -> None:
@@ -903,3 +1064,548 @@ 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).
Costruisce anche `metadata` (una entry per track) per il tagging ID3
+ cover art post-download. Se le creds Spotify sono presenti,
arricchisce con i generi dell'artista (cache locale per artist_id
per evitare N+1 chiamate).
"""
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
# Tentativo di token Spotify per fetch generi (best-effort, opzionale)
cid = (cfg.get("client_id") or "").strip()
secret = (cfg.get("client_secret") or "").strip()
token: Optional[str] = None
if cid and secret:
try:
token = spotify_client.get_access_token(cid, secret)
except Exception:
token = None
genre_cache: dict = {} # artist_id -> [genres]
converted: list = []
metadata: list = []
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})
# Genere: primo genere dell'artista (se disponibile e token OK)
genre = ""
artist_id = (t.get("artist_id") or "").strip()
if token and artist_id:
if artist_id not in genre_cache:
genre_cache[artist_id] = spotify_client.get_artist_genres(token, artist_id)
genres = genre_cache.get(artist_id) or []
if genres:
genre = genres[0]
tn = t.get("track_number") or 0
metadata.append({
"title": title,
"artist": artists,
"album": (t.get("album") or "").strip(),
"date": (t.get("release_date") or "").strip(),
"genre": genre,
"tracknumber": str(tn) if tn else "",
"cover_url": (t.get("cover_url_large") or t.get("image_url") or "").strip(),
})
if not converted:
return {"ok": False, "error": "Nessun brano valido"}
return self.start_tracks_download({
"tracks": converted,
"output_dir": out_root,
"subfolder": subfolder,
"metadata": metadata,
})
# ---- 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).
Per ogni video prova a dedurre i metadati:
1) Se le creds Spotify sono presenti, cerca il titolo su Spotify
e usa i tag Spotify se c'è un match (best-effort).
2) Altrimenti fallback: parsing "Artista - Titolo" dal titolo del
video; uploader come fallback per l'album.
"""
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
# Token Spotify opzionale per enrichment
cid = (cfg.get("client_id") or "").strip()
secret = (cfg.get("client_secret") or "").strip()
token: Optional[str] = None
if cid and secret:
try:
token = spotify_client.get_access_token(cid, secret)
except Exception:
token = None
genre_cache: dict = {} # artist_id -> [genres]
urls: list = []
titles: list = []
metadata: 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)
md: dict = {}
# 1) Enrichment via Spotify search (se token)
if token:
sp = spotify_client.enrich_from_youtube_title(token, title)
if sp:
# Genere: opzionale
genre = ""
artist_id = (sp.get("artist_id") or "").strip()
if artist_id:
if artist_id not in genre_cache:
genre_cache[artist_id] = spotify_client.get_artist_genres(token, artist_id)
genres = genre_cache.get(artist_id) or []
if genres:
genre = genres[0]
tn = sp.get("track_number") or 0
md = {
"title": (sp.get("name") or "").strip(),
"artist": (sp.get("artists") or "").strip(),
"album": (sp.get("album") or "").strip(),
"date": (sp.get("release_date") or "").strip(),
"genre": genre,
"tracknumber": str(tn) if tn else "",
"cover_url": (sp.get("cover_url_large") or sp.get("image_url") or "").strip(),
}
# 2) Fallback: parsing del titolo YouTube
if not md.get("title") or not md.get("artist"):
parsed = _parse_track_line(title)
if parsed:
fb_artist = parsed["artist"]
fb_title = parsed["name"]
else:
fb_artist = ""
fb_title = title
md = {
"title": fb_title,
"artist": fb_artist,
"album": (t.get("channel") or "").strip(),
"date": (t.get("release_date") or "").strip(),
"genre": "",
"tracknumber": "",
"cover_url": (t.get("image_url") or "").strip(),
}
metadata.append(md)
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,
"metadata": metadata,
})
# ================================================================
# WAV -> MP3 converter
# ================================================================
def convert_pick_wav_files(self) -> list:
"""Apre file picker multi-selezione per .wav. Ritorna list di path."""
if not self.window:
return []
try:
filetypes = ("WAV files (*.wav)",)
result = self.window.create_file_dialog(
self._open_dialog_type(),
allow_multiple=True,
file_types=filetypes,
)
except Exception:
return []
if not result:
return []
return [str(p) for p in result if str(p).lower().endswith(".wav")]
def convert_pick_wav_folder(self, recursive: bool = True) -> list:
"""Apre folder picker, scan .wav (opzionalmente ricorsivo)."""
from core import converter
if not self.window:
return []
try:
result = self.window.create_file_dialog(self._folder_dialog_type())
except Exception:
return []
if not result:
return []
folder = str(result[0]) if isinstance(result, (list, tuple)) else str(result)
return converter.list_wav_files(folder, recursive=recursive)
def convert_start(self, payload: dict) -> dict:
"""Avvia conversione batch. Payload:
{files: [str], bitrate: int, vbr: bool, output_dir: str | None}
Se output_dir e vuoto/None, il .mp3 va accanto al .wav.
"""
if self._any_job_running():
return {"ok": False, "error": "Un'operazione e' gia in corso"}
files = payload.get("files") or []
if not files:
return {"ok": False, "error": "Nessun file selezionato"}
try:
bitrate = int(payload.get("bitrate", 320))
except (TypeError, ValueError):
bitrate = 320
if bitrate not in (128, 192, 256, 320):
return {"ok": False, "error": "Bitrate non valido"}
vbr = bool(payload.get("vbr", False))
output_dir = (payload.get("output_dir") or "").strip() or None
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread(
target=self._convert_worker,
args=(list(files), bitrate, vbr, output_dir),
daemon=True,
)
self._download_thread.start()
return {"ok": True}
def convert_stop(self) -> dict:
from core import converter as conv
conv.request_stop()
self._log("convert", "[INFO] Interruzione richiesta...")
return {"ok": True}
def _convert_worker(self, files: list, bitrate: int, vbr: bool,
output_dir: Optional[str]) -> None:
from core import converter as conv
conv.reset_stop()
view = "convert"
total = len(files)
mode = "VBR" if vbr else "CBR"
self._log(view, f"[INFO] Conversione di {total} file (MP3 {bitrate}k {mode})")
if output_dir:
self._log(view, f"[INFO] Destinazione: {output_dir}")
else:
self._log(view, "[INFO] Destinazione: accanto al file originale")
_last = [0.0]
def make_progress_cb(idx: int, filename: str):
def cb(pct: int):
import time as _t
if pct == -1:
# interruzione
self._emit("convert:progress", {
"idx": idx, "total": total, "file": filename,
"status": "stopped", "pct": 0,
})
return
# throttle
now = _t.monotonic()
if pct < 100 and now - _last[0] < 0.10:
return
_last[0] = now
self._emit("convert:progress", {
"idx": idx, "total": total, "file": filename,
"status": "converting" if pct < 100 else "done",
"pct": pct,
"overall": min(((idx + pct / 100) / total), 1.0) if total else 0,
})
return cb
for i, src_path in enumerate(files):
if conv.is_stopped():
self._log(view, "[INFO] Conversione interrotta.")
break
src = Path(src_path)
if not src.exists():
self._log(view, f"[ERRORE] File mancante: {src.name}")
continue
dst_dir = Path(output_dir) if output_dir else src.parent
dst = dst_dir / (src.stem + ".mp3")
# Skip se il .mp3 esiste gia (protezione contro overwrite)
if dst.exists():
self._log(view, f"[SKIP] {dst.name} esiste gia'")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": "skipped", "pct": 100,
"overall": min(((i + 1) / total), 1.0) if total else 1,
})
continue
self._log(view, f"[CONVERTI] {src.name}")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": "converting", "pct": 0,
})
try:
conv.convert_wav_to_mp3(
str(src), str(dst),
bitrate=bitrate, vbr=vbr,
progress_callback=make_progress_cb(i, src.name),
)
self._log(view, f"[OK] {dst.name}")
except Exception as e:
self._log(view, f"[ERRORE] {src.name}: {e}")
self._emit("convert:progress", {
"idx": i, "total": total, "file": src.name,
"status": f"error: {e}", "pct": 0,
})
self._emit("convert:done", {"ok": True})
+2
View File
@@ -226,6 +226,8 @@ def run_pyinstaller():
"--hidden-import", "webview.platforms.cocoa",
"--hidden-import", "requests",
"--collect-all", "mutagen",
# certifi ha cacert.pem necessario per SSL/HTTPS
"--collect-data", "certifi",
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
*add_binaries,
# Entry point
+2
View File
@@ -133,6 +133,8 @@ def run_pyinstaller():
"--collect-all", "pythonnet",
"--collect-all", "clr_loader",
"--collect-all", "mutagen",
# certifi ha il file dati cacert.pem necessario per SSL/HTTPS
"--collect-data", "certifi",
# collect-data prende anche i file .json/.dll non-Python che
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
# in particolare).
+246
View File
@@ -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
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.7.15"
VERSION = "v1.8.6"
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
+176
View File
@@ -0,0 +1,176 @@
"""Conversione WAV -> MP3 via ffmpeg subprocess."""
from __future__ import annotations
import re
import subprocess
import threading
from pathlib import Path
from typing import Callable, Optional
from core.paths import find_ffmpeg_dir, subprocess_flags
_stop_event = threading.Event()
_current_process: Optional[subprocess.Popen] = None
_process_lock = threading.Lock()
def request_stop() -> None:
_stop_event.set()
with _process_lock:
if _current_process and _current_process.poll() is None:
_current_process.terminate()
def reset_stop() -> None:
_stop_event.clear()
def is_stopped() -> bool:
return _stop_event.is_set()
# VBR quality mapping da bitrate label a -q:a (libmp3lame). Piu basso = migliore qualita.
# Vedi https://trac.ffmpeg.org/wiki/Encode/MP3
_VBR_QUALITY = {
128: 5,
192: 2,
256: 0,
320: 0, # V0 e' ~245k medio; per >V0 c'e' solo CBR 320
}
def _find_ffmpeg() -> str:
"""Trova il binario ffmpeg dentro bundle_bin o PATH.
Solleva RuntimeError se non trovato.
"""
ffmpeg_dir = find_ffmpeg_dir()
if ffmpeg_dir:
for name in ("ffmpeg", "ffmpeg.exe"):
p = Path(ffmpeg_dir) / name
if p.exists():
return str(p)
# Fallback PATH
import shutil
ff = shutil.which("ffmpeg")
if ff:
return ff
raise RuntimeError("ffmpeg non trovato nel bundle o nel PATH")
def convert_wav_to_mp3(
input_path: str,
output_path: str,
bitrate: int = 320,
vbr: bool = False,
progress_callback: Optional[Callable] = None,
) -> None:
"""Converte un file WAV in MP3.
Args:
input_path: file .wav sorgente
output_path: file .mp3 destinazione
bitrate: 128 / 192 / 256 / 320
vbr: True per Variable Bit Rate (-q:a), False per Constant (-b:a)
progress_callback: opzionale, chiamato con (percent 0..100)
Raises:
RuntimeError: se ffmpeg non trovato o exit non-zero.
FileNotFoundError: se input_path non esiste.
"""
src = Path(input_path)
if not src.exists():
raise FileNotFoundError(f"File sorgente non trovato: {input_path}")
dst = Path(output_path)
dst.parent.mkdir(parents=True, exist_ok=True)
ffmpeg = _find_ffmpeg()
cmd = [ffmpeg, "-y", "-i", str(src)]
if vbr:
quality = _VBR_QUALITY.get(bitrate, 2)
cmd.extend(["-c:a", "libmp3lame", "-q:a", str(quality)])
else:
cmd.extend(["-c:a", "libmp3lame", "-b:a", f"{bitrate}k"])
cmd.extend([
"-map_metadata", "0", # copia metadata WAV se presenti
"-id3v2_version", "3",
"-progress", "pipe:2", # progress su stderr in formato key=value
str(dst),
])
# Prova a ottenere la durata totale per calcolare % — leggendo lo stream INFO
duration_us = _probe_duration_us(ffmpeg, str(src))
global _current_process
with _process_lock:
_current_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
**subprocess_flags(),
)
proc = _current_process
try:
for line in proc.stderr:
if is_stopped():
proc.terminate()
if progress_callback:
progress_callback(-1) # segnale di interruzione
return
if not progress_callback or not duration_us:
continue
# ffmpeg -progress emette "out_time_us=<microsecondi>"
m = re.match(r"out_time_us=(\d+)", line.strip())
if m:
elapsed = int(m.group(1))
pct = min(100, int(elapsed * 100 / duration_us))
progress_callback(pct)
rc = proc.wait()
if rc != 0:
raise RuntimeError(f"ffmpeg exit {rc}")
if progress_callback:
progress_callback(100)
finally:
with _process_lock:
_current_process = None
def _probe_duration_us(ffmpeg: str, input_path: str) -> int:
"""Estrae la durata in microsecondi via ffmpeg -f null. Ritorna 0 se non riesce."""
try:
# ffprobe potrebbe non essere sempre bundlato; usa ffmpeg
result = subprocess.run(
[ffmpeg, "-i", input_path, "-f", "null", "-"],
capture_output=True,
text=True,
timeout=10,
**subprocess_flags(),
)
# Cerca "Duration: HH:MM:SS.ms"
m = re.search(r"Duration:\s+(\d+):(\d+):(\d+)\.(\d+)", result.stderr)
if m:
h, mi, s, ms = map(int, m.groups())
total_sec = h * 3600 + mi * 60 + s + ms / 100
return int(total_sec * 1_000_000)
except Exception:
pass
return 0
def list_wav_files(directory: str, recursive: bool = True) -> list:
"""Scan cartella per file .wav. Ritorna list di path stringa ordinati."""
root = Path(directory)
if not root.is_dir():
return []
pattern = "**/*.wav" if recursive else "*.wav"
return sorted(str(p) for p in root.glob(pattern) if p.is_file())
+166 -1
View File
@@ -121,6 +121,8 @@ def download_playlist(
bitrate: str = "320K",
cookies_path: Optional[str] = None,
progress_callback: Optional[Callable] = None,
output_filenames: Optional[list] = None,
post_download_callback: Optional[Callable] = None,
) -> None:
"""Scarica tutti i brani dalla lista di tracce.
@@ -130,6 +132,12 @@ def download_playlist(
bitrate: qualita audio (es. "320K")
cookies_path: percorso file cookies (opzionale)
progress_callback: callback(track_index, total, track_name, status, percent)
output_filenames: opzionale, parallelo a `tracks`. Se presente e non
vuoto per l'indice i, yt-dlp scrive `<output_filenames[i]>.mp3`
invece di usare il titolo YouTube. Passa gia' sanitizzato.
post_download_callback: opzionale, chiamato come `(i, filepath)`
dopo yt-dlp exit 0 con il path effettivo del file .mp3 salvato.
Usato per tagging ID3 post-download.
"""
reset_stop()
total = len(tracks)
@@ -183,6 +191,15 @@ def download_playlist(
if progress_callback:
progress_callback(i, total, query, "downloading", 0)
# Nome file custom (per tagging: "Artista - Titolo") oppure titolo YouTube grezzo
custom_stem = ""
if output_filenames and i < len(output_filenames):
custom_stem = (output_filenames[i] or "").strip()
if custom_stem:
out_template = str(Path(output_dir) / f"{custom_stem}.%(ext)s")
else:
out_template = str(Path(output_dir) / "%(title)s.%(ext)s")
# Scarica con yt-dlp subprocess
cmd = [
ytdlp,
@@ -193,7 +210,7 @@ def download_playlist(
"--add-metadata",
"--no-check-certificates",
"--newline",
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
"--output", out_template,
video_url,
]
ffmpeg_dir = find_ffmpeg_dir()
@@ -231,6 +248,12 @@ def download_playlist(
_mark_done(done_file, query)
done_set.add(query)
existing_files = _scan_existing_files(out_path)
if post_download_callback and custom_stem:
mp3_path = Path(output_dir) / f"{custom_stem}.mp3"
try:
post_download_callback(i, str(mp3_path))
except Exception:
pass
if progress_callback:
progress_callback(i, total, query, "done", 100)
else:
@@ -399,6 +422,148 @@ 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,
output_filenames: Optional[list] = None,
post_download_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)
output_filenames: opzionale, parallelo a `urls`. Se presente e non
vuoto per l'indice i, yt-dlp scrive `<output_filenames[i]>.mp3`
invece di usare il titolo YouTube. Passa gia' sanitizzato.
post_download_callback: opzionale, chiamato come `(i, filepath)`
dopo yt-dlp exit 0 con il path effettivo del file .mp3 salvato.
Usato per tagging ID3 post-download.
"""
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)
# Nome file custom (per tagging: "Artista - Titolo") oppure titolo YouTube grezzo
custom_stem = ""
if output_filenames and i < len(output_filenames):
custom_stem = (output_filenames[i] or "").strip()
if custom_stem:
out_template = str(Path(output_dir) / f"{custom_stem}.%(ext)s")
else:
out_template = str(Path(output_dir) / "%(title)s.%(ext)s")
cmd = [
ytdlp,
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", bitrate.rstrip("Kk"),
"--embed-thumbnail",
"--add-metadata",
"--no-check-certificates",
"--newline",
"--output", out_template,
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 post_download_callback and custom_stem:
mp3_path = Path(output_dir) / f"{custom_stem}.mp3"
try:
post_download_callback(i, str(mp3_path))
except Exception:
pass
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":
+211
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import re
import time
import requests
@@ -255,3 +257,212 @@ 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."""
album = t.get("album", {}) or {}
images = album.get("images", []) or []
# Spotify torna 3 taglie ordinate large->small. Prendo la più piccola (~64px)
# per la thumbnail UI, la più grande (~640px) per il tagging cover.
image_url = "" # ~64px per UI thumbnail
cover_url_large = "" # ~640px per tagging ID3
if images:
image_url = images[-1].get("url", "") or images[0].get("url", "")
cover_url_large = images[0].get("url", "") or image_url
# release_date può essere YYYY, YYYY-MM o YYYY-MM-DD in base a release_date_precision
release_date = str(album.get("release_date") or "").strip()
# track_number (opzionale: presente su brani da album, assente su top-tracks flat)
tracknumber = t.get("track_number")
# artist_id (primo artista) per fetchare i generi ID3
artists_arr = t.get("artists", []) or []
artist_id = artists_arr[0].get("id", "") if artists_arr else ""
return {
"id": t.get("id", ""),
"url": t.get("external_urls", {}).get("spotify", ""),
"name": t.get("name", ""),
"artists": ", ".join(a.get("name", "") for a in artists_arr),
"album": album.get("name", ""),
"duration_sec": int(t.get("duration_ms", 0)) // 1000,
"image_url": image_url,
"cover_url_large": cover_url_large,
"release_date": release_date,
"track_number": int(tracknumber) if tracknumber else 0,
"artist_id": artist_id,
}
def get_artist_genres(token: str, artist_id: str) -> list:
"""Ritorna la lista di generi dell'artista (può essere vuota).
Silenzia gli errori HTTP: usato per arricchimento best-effort dei tag
ID3, non deve mai bloccare il download.
"""
if not artist_id:
return []
try:
resp = requests.get(
f"https://api.spotify.com/v1/artists/{artist_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if resp.status_code != 200:
return []
return list(resp.json().get("genres") or [])
except Exception:
return []
def enrich_from_youtube_title(token: str, video_title: str) -> dict:
"""Cerca su Spotify il primo match per il titolo di un video YouTube.
Ritorna un dict con la stessa shape di _track_to_dict, oppure {} se
nessun match / errore. Best-effort per il tagging dei download YouTube.
"""
query = (video_title or "").strip()
if not query:
return {}
try:
results = search_tracks(token, query, limit=1)
except Exception:
return {}
return results[0] if results else {}
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 []
alb_release_date = alb.get("release_date", "")
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,
"release_date": alb_release_date,
})
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
+109
View File
@@ -0,0 +1,109 @@
"""Scrittura tag ID3 su file MP3 via mutagen, con cover art da URL."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
import requests
from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, APIC
from mutagen.mp3 import MP3
SAFE_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def sanitize_filename_stem(stem: str, max_len: int = 180) -> str:
"""Rimuove caratteri non validi per un filename e limita lunghezza."""
s = SAFE_FILENAME_RE.sub("_", stem or "").strip()
s = re.sub(r"\s+", " ", s)
if len(s) > max_len:
s = s[:max_len].rstrip()
return s or "untitled"
def build_filename_stem(artist: str, title: str) -> str:
"""Compone 'Artista - Titolo' sanitizzato."""
artist = (artist or "").strip()
title = (title or "").strip()
if artist and title:
return sanitize_filename_stem(f"{artist} - {title}")
return sanitize_filename_stem(title or artist or "untitled")
def _download_cover(url: str) -> Optional[bytes]:
"""Scarica bytes cover, ritorna None su qualsiasi errore."""
try:
resp = requests.get(url, timeout=15)
if resp.status_code == 200:
return resp.content
except Exception:
pass
return None
def _cover_mime(url: str, content: bytes) -> str:
"""Deduci MIME dell'immagine da URL o magic bytes."""
u = url.lower()
if u.endswith(".png"):
return "image/png"
if u.endswith((".jpg", ".jpeg")):
return "image/jpeg"
# magic bytes
if content[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if content[:3] == b"\xff\xd8\xff":
return "image/jpeg"
return "image/jpeg" # default
def write_tags(filepath: str, metadata: dict) -> None:
"""Scrive ID3 tag su file MP3 esistente.
metadata: dict con chiavi opzionali:
title, artist, album, date (YYYY o YYYY-MM-DD), genre,
tracknumber, cover_url
Errori non fatali (file mancante, tag non scrivibili): silenziosi
per non bloccare il download principale.
"""
p = Path(filepath)
if not p.exists():
return
# 1. Tag testuali via EasyID3
try:
try:
audio = EasyID3(str(p))
except Exception:
# Se il file non ha header ID3, aggiungine uno vuoto
mp3 = MP3(str(p))
if mp3.tags is None:
mp3.add_tags()
mp3.save()
audio = EasyID3(str(p))
for key in ("title", "artist", "album", "date", "genre", "tracknumber"):
val = metadata.get(key)
if val:
audio[key] = str(val)
audio.save(str(p))
except Exception:
return # ID3 broken, non provo la cover
# 2. Cover art via ID3 APIC
cover_url = (metadata.get("cover_url") or "").strip()
if not cover_url:
return
cover_bytes = _download_cover(cover_url)
if not cover_bytes:
return
try:
mime = _cover_mime(cover_url, cover_bytes)
tags = ID3(str(p))
tags.delall("APIC")
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_bytes))
tags.save(str(p))
except Exception:
pass
+74
View File
@@ -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.
+22 -138
View File
@@ -4,10 +4,10 @@
<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" />
@@ -28,11 +28,10 @@
<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>
@@ -40,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.
@@ -48,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">
@@ -157,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>
@@ -177,113 +176,18 @@
<!-- ============== 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">
@@ -356,27 +260,12 @@
<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">
@@ -441,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>
@@ -452,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>
@@ -479,7 +364,6 @@
</div>
</footer>
<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
<script src="js/main.js" defer></script>
</body>
</html>
+15
View File
@@ -4,6 +4,21 @@ import os
import sys
from pathlib import Path
# ============================================================
# Fix SSL CA bundle: quando l'app e' frozen (PyInstaller) i moduli
# ssl e requests non trovano nessun CA bundle di default -> tutte
# le chiamate HTTPS falliscono con CERTIFICATE_VERIFY_FAILED.
# Puntiamo esplicitamente al bundle di certifi (che il build script
# include via `--collect-data certifi`).
# ============================================================
try:
import certifi as _certifi
_ca_bundle = _certifi.where()
os.environ.setdefault("SSL_CERT_FILE", _ca_bundle)
os.environ.setdefault("REQUESTS_CA_BUNDLE", _ca_bundle)
except Exception as _e:
print(f"[bootstrap] certifi setup failed: {_e}")
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare
# webview. Con PyInstaller il lazy-loader fallisce con
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
+7
View File
@@ -1,5 +1,6 @@
pywebview>=5.0
requests>=2.31.0
certifi>=2024.0.0
yt-dlp>=2024.0.0
mutagen>=1.47.0
pyobjc-core>=10.0 ; sys_platform == "darwin"
@@ -8,3 +9,9 @@ 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
+88
View File
@@ -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())
+75
View File
@@ -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();
+33
View File
@@ -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'"
View File
Whitespace-only changes.
+12
View File
@@ -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
View File
@@ -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 &amp; Techno - TOP 100 | Download &amp; Stream Melodic House &amp; Techno on Beatport</title><meta property="og:title" content="Melodic House &amp; Techno - TOP 100 | Download &amp; Stream Melodic House &amp; Techno on Beatport" data-next-head=""/><meta property="og:description" content="Download the best Melodic House &amp; Techno music and stream the top 100 Melodic House &amp; Techno music tracks on Beatport. Explore the Top 100 Melodic House &amp; 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 &amp; Techno - TOP 100 | Download &amp; Stream Melodic House &amp; Techno on Beatport" data-next-head=""/><meta name="twitter:description" content="Download the best Melodic House &amp; Techno music and stream the top 100 Melodic House &amp; Techno music tracks on Beatport. Explore the Top 100 Melodic House &amp; 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 &amp; Techno music and stream the top 100 Melodic House &amp; Techno music tracks on Beatport. Explore the Top 100 Melodic House &amp; 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 &amp; 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&amp;utm_source=bp_store&amp;utm_campaign=2025_evergreen_greenroom&amp;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&amp;utm_medium=banner&amp;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
+190
View File
@@ -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")
+104
View File
@@ -0,0 +1,104 @@
"""Test per core.converter — file scanning + stop event.
I test evitano di invocare ffmpeg reale (che potrebbe non essere in PATH
in CI). La funzione di conversione vera e propria e' coperta manualmente.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from core import converter
class TestListWavFiles:
def test_scans_recursively(self, tmp_path: Path):
# Struttura:
# root/a.wav
# root/b.WAV (case insensitive: glob su POSIX distingue,
# quindi controllo esplicito che almeno i .wav
# minuscoli siano restituiti)
# root/c.mp3
# root/sub/d.wav
(tmp_path / "a.wav").write_bytes(b"RIFF")
(tmp_path / "c.mp3").write_bytes(b"ID3")
sub = tmp_path / "sub"
sub.mkdir()
(sub / "d.wav").write_bytes(b"RIFF")
got = converter.list_wav_files(str(tmp_path), recursive=True)
assert len(got) == 2
names = {Path(p).name for p in got}
assert names == {"a.wav", "d.wav"}
# Ordinamento stabile
assert got == sorted(got)
def test_non_recursive_ignores_subfolders(self, tmp_path: Path):
(tmp_path / "a.wav").write_bytes(b"RIFF")
sub = tmp_path / "sub"
sub.mkdir()
(sub / "b.wav").write_bytes(b"RIFF")
got = converter.list_wav_files(str(tmp_path), recursive=False)
assert len(got) == 1
assert Path(got[0]).name == "a.wav"
def test_empty_dir_returns_empty(self, tmp_path: Path):
assert converter.list_wav_files(str(tmp_path)) == []
def test_missing_dir_returns_empty(self, tmp_path: Path):
missing = tmp_path / "does-not-exist"
assert converter.list_wav_files(str(missing)) == []
def test_ignores_non_wav_files(self, tmp_path: Path):
(tmp_path / "song.mp3").write_bytes(b"ID3")
(tmp_path / "song.flac").write_bytes(b"fLaC")
(tmp_path / "notes.txt").write_text("hello")
assert converter.list_wav_files(str(tmp_path)) == []
class TestStopEvent:
def setup_method(self):
# Ogni test parte con lo stop event pulito.
converter.reset_stop()
def teardown_method(self):
converter.reset_stop()
def test_reset_initially_not_stopped(self):
converter.reset_stop()
assert converter.is_stopped() is False
def test_request_stop_sets_flag(self):
converter.request_stop()
assert converter.is_stopped() is True
def test_reset_after_stop_clears_flag(self):
converter.request_stop()
assert converter.is_stopped() is True
converter.reset_stop()
assert converter.is_stopped() is False
class TestConvertWavToMp3Errors:
def test_missing_input_raises(self, tmp_path: Path):
missing = tmp_path / "nope.wav"
out = tmp_path / "out.mp3"
with pytest.raises(FileNotFoundError):
converter.convert_wav_to_mp3(str(missing), str(out))
class TestVbrQualityMap:
def test_all_bitrates_have_mapping(self):
for br in (128, 192, 256, 320):
assert br in converter._VBR_QUALITY
def test_higher_bitrate_lower_or_equal_quality_number(self):
# -q:a: piu' basso = migliore. Coerenza monotona sulle chiavi.
vals = [converter._VBR_QUALITY[br] for br in (128, 192, 256, 320)]
assert vals == sorted(vals, reverse=True)
+333
View File
@@ -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
+57
View File
@@ -0,0 +1,57 @@
"""Test per core.tagger — filename sanitization + write_tags safety net."""
from __future__ import annotations
from core import tagger
class TestSanitizeFilenameStem:
def test_removes_bad_chars(self):
# Tutti i char vietati su Windows/macOS: < > : " / \ | ? *
got = tagger.sanitize_filename_stem('bad<name>:"/\\|?*test')
# Nessuno dei char proibiti sopravvive
for ch in '<>:"/\\|?*':
assert ch not in got
# E ho ottenuto qualcosa di non vuoto
assert got
def test_collapses_whitespace(self):
assert tagger.sanitize_filename_stem("a b c") == "a b c"
def test_truncates_long_stem(self):
s = "x" * 500
got = tagger.sanitize_filename_stem(s, max_len=180)
assert len(got) <= 180
def test_empty_returns_untitled(self):
assert tagger.sanitize_filename_stem("") == "untitled"
assert tagger.sanitize_filename_stem(" ") == "untitled"
class TestBuildFilenameStem:
def test_format_artist_dash_title(self):
assert tagger.build_filename_stem("Kapuchon", "Hot Sauce") == "Kapuchon - Hot Sauce"
def test_multiple_artists_ok(self):
got = tagger.build_filename_stem("Kapuchon, Miss Monique & GLZ", "Hot Sauce (Extended)")
assert got == "Kapuchon, Miss Monique & GLZ - Hot Sauce (Extended)"
def test_missing_artist_uses_title_only(self):
assert tagger.build_filename_stem("", "Only Title") == "Only Title"
def test_missing_title_uses_artist_only(self):
assert tagger.build_filename_stem("Only Artist", "") == "Only Artist"
def test_sanitizes_bad_chars(self):
got = tagger.build_filename_stem("A/B", "C:D")
# Nessuno slash o colon residuo
assert "/" not in got
assert ":" not in got
class TestWriteTagsMissingFile:
def test_missing_file_is_noop(self):
# Non deve sollevare alcuna eccezione se il file non esiste
tagger.write_tags("/nonexistent/path/to/file.mp3", {
"title": "x", "artist": "y", "cover_url": "http://example.com/x.jpg",
})
+84
View File
@@ -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)
+179 -21
View File
@@ -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); }
@@ -893,7 +914,7 @@ input[type="number"]::-webkit-inner-spin-button {
}
.save-status {
color: var(--green);
color: var(--green-text);
font-weight: 700;
font-size: 13px;
opacity: 0;
@@ -1003,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)
@@ -1011,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); }
@@ -1550,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; }
}
+275
View File
@@ -29,6 +29,22 @@
<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="convert" data-feature="audio">
<span class="nav-icon">🔄</span>
<span>Converti</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
@@ -849,6 +865,265 @@
</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>
<!-- ===== VIEW: CONVERTI (WAV -> MP3) ===== -->
<section class="view" id="view-convert">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">WAV → MP3</div>
<h1 class="hero-title">Converti file WAV in MP3</h1>
<p class="hero-subtitle">Bitrate configurabile, CBR o VBR, output accanto al file o in cartella custom.</p>
</div>
<div class="hero-deco">🔄</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="beatport-header">
<button id="convert-pick-files" class="btn btn-primary pill">📄 Scegli file .wav</button>
<button id="convert-pick-folder" class="btn btn-ghost pill">📁 Scegli cartella</button>
<button id="convert-clear" class="btn btn-ghost pill">Svuota</button>
</div>
<div class="beatport-header" style="margin-top:12px;gap:16px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
Bitrate:
<select id="convert-bitrate" class="input pill input-sm">
<option value="128">128 kbps</option>
<option value="192">192 kbps</option>
<option value="256">256 kbps</option>
<option value="320" selected>320 kbps</option>
</select>
</label>
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="convert-vbr" />
<span>VBR (qualità variabile)</span>
</label>
</div>
<div class="beatport-header" style="margin-top:12px;gap:12px;">
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="radio" name="convert-out" id="convert-out-same" checked />
<span>Accanto al file originale</span>
</label>
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="radio" name="convert-out" id="convert-out-custom" />
<span>Cartella custom:</span>
</label>
<button id="convert-out-browse" class="btn btn-ghost pill btn-sm" disabled>Sfoglia…</button>
<span id="convert-out-path" class="beatport-output-info" style="margin:0;"></span>
</div>
<div id="convert-status" class="beatport-status"></div>
</div>
<div class="card beatport-table-card" id="convert-table-wrap" hidden>
<div class="beatport-table-wrap">
<table class="beatport-table" id="convert-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="convert-select-all" checked /></th>
<th class="col-pos">#</th>
<th>File</th>
<th class="col-state">Stato</th>
</tr>
</thead>
<tbody id="convert-tbody"></tbody>
</table>
</div>
<div class="beatport-toolbar" id="convert-toolbar">
<span class="counter" id="convert-selected-count">0/0 selezionati</span>
<button id="convert-run-btn" class="btn btn-primary pill" disabled>
<span class="ico">▶</span> Converti selezionati
</button>
<button id="convert-stop-btn" class="btn btn-danger pill" hidden>
<span class="ico">◼</span> Interrompi
</button>
</div>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="convert-log"></div>
</div>
</section>
</main>
</div>
+1004
View File
File diff suppressed because it is too large. Load diff