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.
This commit is contained in:
luciano committed 2026-07-14 16:48:36 +02:00
commit 48a6e15c7a
15 files changed
+3321 -1

No files matched your search

+103
View File
@@ -8,9 +8,12 @@ import re
import threading import threading
import time import time
import webbrowser import webbrowser
from dataclasses import asdict
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from core.config import load_config, save_config, VERSION, LICENSE_API_URL from core.config import load_config, save_config, VERSION, LICENSE_API_URL
from core import beatport
from core import license as license_mod from core import license as license_mod
from core.downloader import ( from core.downloader import (
download_playlist, download_playlist,
@@ -903,3 +906,103 @@ class Api:
}) })
self._emit("video:done", {"ok": True}) 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,
})
+233
View File
@@ -0,0 +1,233 @@
"""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
@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 _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),
)
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
+3 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.7.15" VERSION = "v1.8.0"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
@@ -70,6 +70,8 @@ DEFAULTS = {
"cookies_path": str(_project_dir / "cookies.txt"), "cookies_path": str(_project_dir / "cookies.txt"),
"output_dir": str(_project_dir / "MUSICA"), "output_dir": str(_project_dir / "MUSICA"),
"theme": "dark", "theme": "dark",
# ---- Beatport ----
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
# ---- Licenza ---- # ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email "license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto "license_email": "", # email associata all'acquisto
+25
View File
@@ -255,3 +255,28 @@ def resolve_spotify_track_no_auth(url: str) -> tuple[str, str, list[dict]]:
track = {"name": name, "artist": artist} track = {"name": name, "artist": artist}
return "Brano", name, [track] 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", [])),
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -8,3 +8,9 @@ pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin" pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
pythonnet==3.0.5 ; sys_platform == "win32" pythonnet==3.0.5 ; sys_platform == "win32"
clr-loader==0.2.7.post0 ; 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())
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")
+70
View File
@@ -0,0 +1,70 @@
"""Test per core.spotify_client.search_track()."""
from __future__ import annotations
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"
+128
View File
@@ -1550,3 +1550,131 @@ input[type="number"]::-webkit-inner-spin-button {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; 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); 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-hover);
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: rgba(99, 102, 241, 0.06);
}
.beatport-table tbody tr.already-downloaded td {
color: var(--text-3);
}
.beatport-table tbody tr.already-downloaded .col-state {
color: var(--green);
font-weight: 600;
}
.beatport-table .col-check { width: 36px; }
.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; }
}
+61
View File
@@ -29,6 +29,10 @@
<span class="nav-icon">🎬</span> <span class="nav-icon">🎬</span>
<span>Video</span> <span>Video</span>
</button> </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="upgrade" data-feature="upgrade"> <button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span> <span class="nav-icon">⚡</span>
<span>Upgrade</span> <span>Upgrade</span>
@@ -849,6 +853,63 @@
</div> </div>
</section> </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-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>
</main> </main>
</div> </div>
+293
View File
@@ -501,6 +501,9 @@ async function init() {
$("#recPathDisplay").classList.remove("empty"); $("#recPathDisplay").classList.remove("empty");
} }
await refreshRecDevices(); await refreshRecDevices();
// Beatport tab — popola generi, ripristina ultimo genere, aggancia handler
await BeatportUI.init();
} }
async function refreshRecDevices() { async function refreshRecDevices() {
@@ -1170,6 +1173,296 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => {
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {} try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
}); });
// ============================================================
// BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica
// (il download riusa start_tracks_download, quindi passa dagli stessi
// canali "download:progress" / "download:done" / log view="download")
// ============================================================
const BeatportUI = (function () {
let genres = [];
let currentTracks = [];
let existing = [];
let currentGenreName = "";
let currentGenreSlug = "";
let downloading = false;
function fmtDur(sec) {
const n = Number(sec) || 0;
const m = Math.floor(n / 60);
const s = Math.floor(n % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function safeGenreFolder(name) {
return String(name || "").replace(/[\/\\]/g, "_").trim();
}
function populateSelect() {
const sel = $("#beatport-genre");
if (!sel) return;
sel.innerHTML = "";
for (const g of genres) {
const opt = document.createElement("option");
opt.value = g.slug;
opt.textContent = g.name;
sel.appendChild(opt);
}
}
function updateOutputInfo() {
const info = $("#beatport-output-info");
if (!info) return;
const root = (state.config && state.config.output_dir) || "";
if (!root) {
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
info.className = "beatport-output-info warn";
return;
}
const folder = safeGenreFolder(currentGenreName);
info.textContent = folder
? `Destinazione: ${root}/Beatport/${folder}`
: `Destinazione: ${root}/Beatport`;
info.className = "beatport-output-info";
}
function setStatus(text, kind) {
const el = $("#beatport-status");
if (!el) return;
el.textContent = text || "";
el.className = "beatport-status" + (kind ? " " + kind : "");
}
async function loadChart(forceRefresh) {
const sel = $("#beatport-genre");
const slug = sel && sel.value;
if (!slug) return;
const genreName = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || slug;
currentGenreSlug = slug;
currentGenreName = genreName;
setStatus(forceRefresh ? "Ricarico Top 100 (bypass cache)…" : "Caricamento Top 100…", "loading");
$("#beatport-table").hidden = true;
$("#beatport-toolbar").hidden = true;
$("#beatport-table").querySelector("tbody").innerHTML = "";
let res;
try {
res = await window.pywebview.api.beatport_fetch_chart(slug, !!forceRefresh);
} catch (e) {
setStatus("Errore: " + ((e && e.message) || e), "error");
return;
}
if (!res || !res.ok) {
const code = res && res.error;
let msg = (res && res.message) || "Errore sconosciuto";
if (code === "invalid_genre") msg = "Genere non valido";
else if (code === "unreachable") msg = "Beatport non raggiungibile — riprova più tardi";
else if (code === "parse") msg = "Impossibile leggere i dati (schema pagina cambiato?)";
setStatus(msg, "error");
return;
}
currentTracks = res.tracks || [];
try {
existing = await window.pywebview.api.beatport_check_existing(currentTracks, currentGenreName);
} catch (_e) {
existing = currentTracks.map(() => false);
}
setStatus(`${currentTracks.length} brani caricati`, "ok");
renderTable();
}
function renderTable() {
const table = $("#beatport-table");
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
currentTracks.forEach((t, i) => {
const tr = document.createElement("tr");
if (existing[i]) tr.classList.add("already-downloaded");
const tdCheck = document.createElement("td");
tdCheck.className = "col-check";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.dataset.idx = String(i);
cb.checked = !existing[i];
cb.addEventListener("change", updateSelectionCount);
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
const tdPos = document.createElement("td");
tdPos.className = "col-pos";
tdPos.textContent = String(t.position || (i + 1));
tr.appendChild(tdPos);
const tdArtist = document.createElement("td");
tdArtist.textContent = t.artists || "";
tr.appendChild(tdArtist);
const tdTitle = document.createElement("td");
tdTitle.textContent = t.mix ? `${t.title} (${t.mix})` : (t.title || "");
tr.appendChild(tdTitle);
const tdDur = document.createElement("td");
tdDur.className = "col-dur";
tdDur.textContent = fmtDur(t.duration_sec);
tr.appendChild(tdDur);
const tdState = document.createElement("td");
tdState.className = "col-state";
if (existing[i]) tdState.textContent = "✓ già scaricato";
tr.appendChild(tdState);
tbody.appendChild(tr);
});
table.hidden = false;
$("#beatport-toolbar").hidden = false;
updateOutputInfo();
updateSelectionCount();
}
function updateSelectionCount() {
const boxes = $$("#beatport-table tbody input[type=checkbox]");
const total = boxes.length;
const checked = boxes.filter((b) => b.checked).length;
$("#beatport-selected-count").textContent = `${checked}/${total} selezionati`;
const dlBtn = $("#beatport-download-btn");
if (dlBtn) dlBtn.disabled = downloading || checked === 0;
const master = $("#beatport-select-all");
if (master) {
if (total === 0) { master.checked = false; master.indeterminate = false; }
else if (checked === 0) { master.checked = false; master.indeterminate = false; }
else if (checked === total) { master.checked = true; master.indeterminate = false; }
else { master.checked = false; master.indeterminate = true; }
}
}
function toggleAll(checked) {
$$("#beatport-table tbody input[type=checkbox]").forEach((b) => { b.checked = checked; });
updateSelectionCount();
}
function appendBeatportLog(msg) {
const el = $("#beatport-log");
if (!el) return;
const cls = classifyLog(msg);
const line = document.createElement("div");
if (cls) line.className = cls;
line.textContent = msg;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
}
async function startDownload() {
const boxes = $$("#beatport-table tbody input[type=checkbox]");
const selected = [];
boxes.forEach((b) => {
if (b.checked) {
const idx = parseInt(b.dataset.idx, 10);
if (!isNaN(idx) && currentTracks[idx]) selected.push(currentTracks[idx]);
}
});
if (!selected.length) {
toast("Seleziona almeno un brano", "error");
return;
}
if (!state.config || !state.config.output_dir) {
toast("Imposta la cartella output in Impostazioni", "error");
return;
}
downloading = true;
$("#beatport-download-btn").disabled = true;
$("#beatport-stop-btn").hidden = false;
$("#beatport-stop-btn").disabled = false;
$("#beatport-log").innerHTML = "";
appendBeatportLog(`[INFO] Avvio download di ${selected.length} brani da ${currentGenreName}…`);
let res;
try {
res = await window.pywebview.api.beatport_download_selected(selected, currentGenreName);
} catch (e) {
appendBeatportLog("[ERRORE] " + ((e && e.message) || e));
finishDownload();
return;
}
if (!res || !res.ok) {
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
appendBeatportLog("[ERRORE] " + errMsg);
if (!handleGateBlock(res || {})) toast(errMsg, "error");
finishDownload();
}
}
async function stopDownload() {
try {
await window.pywebview.api.stop_download();
} catch (e) { console.error(e); }
}
function finishDownload() {
downloading = false;
$("#beatport-stop-btn").hidden = true;
$("#beatport-stop-btn").disabled = true;
updateSelectionCount();
// Re-check existing per aggiornare i badge "già scaricato"
if (currentTracks.length) {
window.pywebview.api.beatport_check_existing(currentTracks, currentGenreName)
.then((res) => { existing = res || existing; renderTable(); })
.catch(() => {});
}
}
async function init() {
// Popola dropdown generi
try {
genres = await window.pywebview.api.beatport_genres();
} catch (e) {
console.error("Beatport: impossibile caricare i generi", e);
genres = [];
}
populateSelect();
// Ripristina l'ultimo genere selezionato (state.config gia' caricato)
const last = (state.config && state.config.beatport_last_genre) || "";
const sel = $("#beatport-genre");
if (sel && last && genres.some((g) => g.slug === last)) {
sel.value = last;
}
updateOutputInfo();
// Bind eventi
$("#beatport-load-btn").addEventListener("click", (e) => loadChart(e.shiftKey));
$("#beatport-download-btn").addEventListener("click", startDownload);
$("#beatport-stop-btn").addEventListener("click", stopDownload);
$("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked));
// Aggancia agli event listener esistenti del canale "download" senza rompere
// il comportamento della tab Scarica (wrapping additivo).
const origProgress = bridgeHandlers["download:progress"];
bridgeHandlers["download:progress"] = (p) => {
if (origProgress) origProgress(p);
// I contatori del tab Scarica sono aggiornati dall'handler originale;
// qui potremmo aggiungere un contatore Beatport, ma i log sono sufficienti.
};
const origDone = bridgeHandlers["download:done"];
bridgeHandlers["download:done"] = (p) => {
if (origDone) origDone(p);
if (downloading) finishDownload();
};
const origLog = bridgeHandlers["log"];
bridgeHandlers["log"] = ({ view, msg }) => {
if (origLog) origLog({ view, msg });
if (downloading && view === "download") appendBeatportLog(msg);
};
}
return { init };
})();
// ============================================================ // ============================================================
// Boot // Boot
// ============================================================ // ============================================================