Compare commits

...
22 Commits
Author SHA1 Message Date
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
luzadev dc6ae1fbe1 v1.7.15: patch_info_plist con plistlib (PlistBuddy falliva su apostrofi)
Crash report v1.7.14 ha rivelato che NSMicrophoneUsageDescription
NON era stato scritto nell'Info.plist distribuito. Causa: il valore
contiene l'apostrofo "l'audio" che rompe PlistBuddy con quote
shell. PlistBuddy falliva ma restituiva exit code != 0 con un
messaggio diverso da "already exists", quindi anche il fallback
Set non veniva chiamato -> chiave mai aggiunta.

Riscritto patch_info_plist con plistlib di Python: nessun shell
escaping, robusto a qualsiasi carattere. Aggiunto check di verifica
finale che logga errore critico se la chiave manca dopo write.
2026-06-09 23:17:08 +02:00
luzadev 0e14d161d0 v1.7.14: fix definitivo registrazione macOS — AVCaptureSession in-process
Dopo che codesign --info-plist e wrap in sub-bundle .app si sono
rivelati strade morte (macOS 14+ TCC e' troppo severo per ad-hoc
signed binaries), riscritta la registrazione macOS per usare
PyObjC + AVCaptureSession nel processo Python principale.

Il main MusicTools ha il TCC del bundle (NSMicrophoneUsageDescription
nell'Info.plist), e usando AVFoundation IN-PROCESS il permesso
viene applicato correttamente. ffmpeg subprocess viene usato SOLO
per la conversione CAF -> MP3 dopo lo stop (operazione su file,
niente accesso microfono = niente TCC).

Aggiunto pyobjc-framework-AVFoundation a requirements.
core/paths.py: ripristinato lookup semplice in Contents/Frameworks/
(non serve piu' cercare in sub-bundle).
build_macos.py: wrap_subprocess_in_bundle disabilitato.

Diagnosi crash report v1.7.13 + investigazione TCC behavior
confermano: PyObjC AVCaptureSession in-process e' l'unica
soluzione affidabile senza Developer ID Apple.

Vedi: https://www.qt.io/blog/the-curious-case-of-the-responsible-process
2026-06-09 23:04:16 +02:00
luzadev 7dd793253b v1.7.13: fix definitivo TCC subprocess - sub-bundle .app per ffmpeg/ffprobe
Crash report ha dato la spiegazione precisa:
"Namespace TCC, Code 0 - This app has crashed because it attempted
to access privacy-sensitive data without a usage description.
The app's Info.plist must contain an NSMicrophoneUsageDescription key."

macOS 14+ richiede che ogni binario che apre device privacy-sensitive
abbia il suo PROPRIO Info.plist con NSMicrophoneUsageDescription, NON
basta che il main MusicTools lo abbia. Per binari standalone come
ffmpeg, non c'e' Info.plist associato -> SIGABRT immediato.

Fix: incapsulare ffmpeg e ffprobe in mini-bundle .app dentro
MusicTools.app/Contents/Frameworks/ffmpeg.app/Contents/MacOS/ffmpeg
con il proprio Info.plist contenente NSMicrophoneUsageDescription.
Aggiunto rpath @executable_path/../../../ per puntare alle dylib
di Contents/Frameworks/. Codesign di ogni sub-bundle con identifier
com.djluza.musictools.{ffmpeg,ffprobe}.

core/paths.py aggiornato per cercare i binari anche dentro i
sub-bundle .app oltre che in Contents/Frameworks/ legacy.

L'utente al primo lancio della registrazione vedra' un prompt
microfono per "ffmpeg" (in aggiunta a quello del main MusicTools).
2026-06-09 22:44:58 +02:00
22 changed files with 4297 additions and 428 deletions

No files matched your search

+103
View File
@@ -8,9 +8,12 @@ import re
import threading
import time
import webbrowser
from dataclasses import asdict
from pathlib import Path
from typing import Any, Optional
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.downloader import (
download_playlist,
@@ -903,3 +906,103 @@ 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,
})
+140 -30
View File
@@ -239,6 +239,12 @@ def run_pyinstaller():
if app_path.exists():
log(f"Build completata: {app_path}")
patch_info_plist(app_path)
# NOTA: dal v1.7.14 il microfono viene letto direttamente da
# PyObjC/AVCaptureSession nel processo Python (che ha il TCC del
# bundle), non da ffmpeg subprocess. ffmpeg fa solo la conversione
# CAF -> MP3 (no microfono = no TCC). Per questo non serve piu'
# incapsularlo in un sub-bundle .app con NSMicrophoneUsageDescription.
# wrap_subprocess_in_bundle(app_path)
adhoc_codesign(app_path)
# Mostra dimensione
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
@@ -252,50 +258,128 @@ def run_pyinstaller():
def patch_info_plist(app_path):
"""Aggiunge le chiavi TCC (Transparency, Consent and Control) richieste
da macOS nel bundle .app, altrimenti macOS nega silenziosamente l'accesso
al microfono e l'app NON compare in Impostazioni > Privacy e sicurezza.
"""Aggiunge le chiavi TCC nell'Info.plist del bundle .app.
Senza NSMicrophoneUsageDescription, qualsiasi tentativo di ffmpeg di
aprire un dispositivo audio (incluso BlackHole) viene rifiutato dal
sistema e non viene mostrato nessun prompt all'utente.
NSMicrophoneUsageDescription e' obbligatoria: senza, macOS uccide
l'app con SIGABRT quando AVFoundation tenta di aprire il microfono.
Usiamo plistlib invece di PlistBuddy perche' i valori con apostrofi
(es. "l'audio") rompono il parser shell-based di PlistBuddy.
"""
import plistlib
info_plist = app_path / "Contents" / "Info.plist"
if not info_plist.exists():
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
return
pb = "/usr/libexec/PlistBuddy"
# Helper: aggiungi una chiave; se gia presente sovrascrivila.
def add_or_set(key, value, ptype="string"):
# Tenta Add; se esiste, fa Set.
r = subprocess.run(
[pb, "-c", f'Add :{key} {ptype} "{value}"', str(info_plist)],
capture_output=True, text=True,
)
if r.returncode != 0 and "already exists" in (r.stderr + r.stdout).lower():
subprocess.run(
[pb, "-c", f'Set :{key} "{value}"', str(info_plist)],
check=True,
)
log("Patching Info.plist con le chiavi TCC...")
add_or_set(
"NSMicrophoneUsageDescription",
with open(info_plist, "rb") as f:
data = plistlib.load(f)
data["NSMicrophoneUsageDescription"] = (
"MusicTools usa il microfono per registrare l'audio del sistema "
"(ad esempio tramite BlackHole o un altro dispositivo loopback).",
"(ad esempio tramite BlackHole o un altro dispositivo loopback)."
)
# Sicurezza extra: dichiarare bundle identifier stabile aiuta il
# database TCC a riconoscere coerentemente l'app tra le sessioni.
add_or_set("CFBundleIdentifier", "com.djluza.musictools")
add_or_set("LSApplicationCategoryType", "public.app-category.music")
log("Info.plist aggiornato.")
# Bundle identifier stabile aiuta TCC a riconoscere l'app tra le sessioni
data["CFBundleIdentifier"] = "com.djluza.musictools"
data["LSApplicationCategoryType"] = "public.app-category.music"
with open(info_plist, "wb") as f:
plistlib.dump(data, f)
log(f"Info.plist aggiornato. Chiavi: {sorted(data.keys())[-5:]}")
# Verifica
with open(info_plist, "rb") as f:
verify = plistlib.load(f)
if "NSMicrophoneUsageDescription" not in verify:
log("ERRORE CRITICO: NSMicrophoneUsageDescription NON e' stata scritta!")
else:
log("Verifica OK: NSMicrophoneUsageDescription presente.")
_BUNDLE_ID = "com.djluza.musictools"
def _subprocess_info_plist(executable, bundle_id):
"""Info.plist per un mini-bundle che incapsula un binario subprocess.
NSMicrophoneUsageDescription qui dentro e' la chiave: senza di essa,
macOS uccide il subprocess con SIGABRT appena tenta di aprire un
device audio, anche se il main MusicTools ha il proprio TCC concesso.
"""
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
'<plist version="1.0">\n'
'<dict>\n'
f' <key>CFBundleExecutable</key><string>{executable}</string>\n'
f' <key>CFBundleIdentifier</key><string>{bundle_id}</string>\n'
f' <key>CFBundleName</key><string>{executable}</string>\n'
' <key>CFBundlePackageType</key><string>APPL</string>\n'
' <key>CFBundleShortVersionString</key><string>1.0</string>\n'
' <key>CFBundleVersion</key><string>1</string>\n'
' <key>LSBackgroundOnly</key><true/>\n'
' <key>LSUIElement</key><true/>\n'
' <key>NSMicrophoneUsageDescription</key>\n'
' <string>MusicTools usa il microfono per registrare l\'audio del '
'sistema (BlackHole o altri dispositivi loopback).</string>\n'
'</dict>\n'
'</plist>\n'
)
def wrap_subprocess_in_bundle(app_path):
"""Sposta ffmpeg e ffprobe in mini-bundle .app dentro Frameworks/.
macOS 14+ ENFORCE il fatto che ogni binario che apre device
privacy-sensitive (microfono, camera, ...) debba avere un proprio
Info.plist accessibile con NSMicrophoneUsageDescription. Quando
ffmpeg viene lanciato come binario standalone (anche se figlio di
MusicTools), non c'e' Info.plist associato -> SIGABRT.
Soluzione: incapsulare ffmpeg in MusicTools.app/Contents/Frameworks/
ffmpeg.app/Contents/MacOS/ffmpeg, con Info.plist nel suo bundle.
Stesso per ffprobe.
"""
log("Wrapping ffmpeg e ffprobe in sub-bundle .app...")
fw = app_path / "Contents" / "Frameworks"
if not fw.exists():
log("ATTENZIONE: Contents/Frameworks non trovato, skip wrapping.")
return
for name in ("ffmpeg", "ffprobe"):
src = fw / name
if not src.exists() or not src.is_file():
log(f" skip {name}: non trovato in Frameworks/")
continue
wrap = fw / f"{name}.app"
if wrap.exists():
shutil.rmtree(wrap)
macos = wrap / "Contents" / "MacOS"
macos.mkdir(parents=True, exist_ok=True)
dest = macos / name
shutil.move(str(src), str(dest))
dest.chmod(0o755)
# Aggiungi un nuovo rpath che punta a Contents/Frameworks/ (dove
# stanno le dylib del bundle MusicTools).
# @executable_path = ffmpeg.app/Contents/MacOS/
# ../../../ = MusicTools.app/Contents/Frameworks/
subprocess.run(
["install_name_tool", "-add_rpath",
"@executable_path/../../../", str(dest)],
capture_output=True,
)
# Crea Info.plist con NSMicrophoneUsageDescription.
info = wrap / "Contents" / "Info.plist"
bundle_id = f"com.djluza.musictools.{name}"
info.write_text(_subprocess_info_plist(name, bundle_id))
log(f" wrap: {name} -> {dest.relative_to(app_path)}")
def adhoc_codesign(app_path):
"""Ad-hoc codesign dell'intero bundle .app con identifier coerente.
@@ -340,9 +424,19 @@ def adhoc_codesign(app_path):
log(f"Trovati {len(nested_machos)} binari Mach-O da firmare.")
for p in nested_machos:
# Per ffmpeg/ffprobe dentro al sub-bundle .app, usa l'identifier
# del SUB-bundle (TCC li tratta come app separate con Info.plist
# proprio). Per gli altri binari nested (dylib, framework), usa
# l'identifier del bundle principale.
ident = _BUNDLE_ID
parts = p.relative_to(app_path).parts
for sub in ("ffmpeg.app", "ffprobe.app"):
if sub in parts:
ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
break
r = subprocess.run(
["codesign", "--force", "--sign", "-",
"--identifier", _BUNDLE_ID,
"--identifier", ident,
"--timestamp=none",
str(p)],
capture_output=True, text=True,
@@ -350,6 +444,22 @@ def adhoc_codesign(app_path):
if r.returncode != 0:
log(f" warn: firma {p.name} fallita: {r.stderr.strip()}")
# Firma anche i sub-bundle .app come bundle (con il loro Info.plist).
for sub in ("ffmpeg.app", "ffprobe.app"):
sub_path = app_path / "Contents" / "Frameworks" / sub
if not sub_path.exists():
continue
sub_ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
r = subprocess.run(
["codesign", "--force", "--sign", "-",
"--identifier", sub_ident,
"--timestamp=none",
str(sub_path)],
capture_output=True, text=True,
)
if r.returncode != 0:
log(f" warn: firma {sub} fallita: {r.stderr.strip()}")
# 3) Firma del bundle .app principale (l'identifier corretto viene
# letto dall'Info.plist - CFBundleIdentifier che gia' settiamo).
r = subprocess.run(
+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
from pathlib import Path
VERSION = "v1.7.12"
VERSION = "v1.8.0"
APP_NAME = "MusicTools"
@@ -70,6 +70,8 @@ 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
# ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto
+31 -34
View File
@@ -33,11 +33,18 @@ def _bundle_dirs() -> list[Path]:
# Directory dell'eseguibile
exe_dir = Path(sys.executable).parent
dirs.append(exe_dir)
# macOS: Contents/Frameworks/ (PyInstaller onedir .app)
# macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle
# ffmpeg.app / ffprobe.app dove sono incapsulati per TCC.
if not _IS_WINDOWS:
frameworks = exe_dir.parent / "Frameworks"
if frameworks.exists():
dirs.append(frameworks)
# Mini-bundle .app per i subprocess (vedi build_macos.py
# wrap_subprocess_in_bundle).
for sub in ("ffmpeg.app", "ffprobe.app"):
sub_macos = frameworks / sub / "Contents" / "MacOS"
if sub_macos.exists():
dirs.append(sub_macos)
return dirs
@@ -96,55 +103,45 @@ def find_ytdlp() -> str:
raise FileNotFoundError(msg)
def find_ffmpeg_dir() -> Optional[str]:
"""Trova la directory contenente ffmpeg e ffprobe.
Ordine di ricerca:
1. Bundle PyInstaller
2. Percorsi noti per OS
3. Qualsiasi posizione nel PATH
"""
ffmpeg_name = _exe("ffmpeg")
# 1. Bundle
def _find_binary(name: str) -> Optional[str]:
"""Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
exe_name = _exe(name)
# 1. Bundle (incluso eventuali sub-bundle .app su macOS)
for d in _bundle_dirs():
if (d / ffmpeg_name).exists():
return str(d)
# 2. Percorsi noti
candidate = d / exe_name
if candidate.exists():
return str(candidate)
# 2. Percorsi noti per OS
if _IS_WINDOWS:
# Posizioni comuni su Windows
for search in (
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
Path("C:/ffmpeg/bin"),
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
):
if (search / "ffmpeg.exe").exists():
return str(search)
cand = search / exe_name
if cand.exists():
return str(cand)
else:
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
if (Path(search_path) / "ffmpeg").exists():
return search_path
cand = Path(search_path) / exe_name
if cand.exists():
return str(cand)
# 3. PATH generico
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
return str(Path(ffmpeg).parent)
found = shutil.which(name)
return found if found else None
return None
def find_ffmpeg_dir() -> Optional[str]:
"""Ritorna la directory contenente ffmpeg (utile per PATH env)."""
ff = _find_binary("ffmpeg")
return str(Path(ff).parent) if ff else None
def find_ffmpeg() -> Optional[str]:
"""Ritorna il path completo di ffmpeg."""
d = find_ffmpeg_dir()
if d:
return str(Path(d) / _exe("ffmpeg"))
return None
return _find_binary("ffmpeg")
def find_ffprobe() -> Optional[str]:
"""Ritorna il path completo di ffprobe."""
d = find_ffmpeg_dir()
if d:
return str(Path(d) / _exe("ffprobe"))
return None
return _find_binary("ffprobe")
+349 -225
View File
@@ -1,7 +1,10 @@
"""Registrazione audio da un dispositivo di input via ffmpeg.
"""Registrazione audio.
macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole)
Windows: usa DirectShow
macOS: AVCaptureSession via PyObjC dentro al processo Python principale.
Il bundle MusicTools.app ha NSMicrophoneUsageDescription e il TCC viene
concesso al main, quindi AVFoundation registra senza problemi. ffmpeg
viene usato SOLO per convertire il file CAF a MP3 (no microfono = no TCC).
Windows: ffmpeg DirectShow (qui il problema TCC non esiste).
"""
from __future__ import annotations
@@ -25,38 +28,33 @@ _IS_WIN = sys.platform == "win32"
# ============================================================
# Stato globale (un solo recording alla volta)
# ============================================================
_current_proc: Optional[subprocess.Popen] = None
_proc_lock = threading.Lock()
_stop_event = threading.Event()
_recording_thread: Optional[threading.Thread] = None
_recording_lock = threading.Lock()
_recording_state: dict = {}
_stderr_buf: list = []
_stderr_lock = threading.Lock()
def is_recording() -> bool:
with _proc_lock:
return _current_proc is not None and _current_proc.poll() is None
with _recording_lock:
return bool(_recording_state.get("active"))
# ============================================================
# Lista dispositivi
# ============================================================
def list_input_devices() -> list[dict]:
"""Ritorna la lista dei dispositivi audio di input.
Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}.
def get_last_stderr(max_lines: int = 50) -> list[str]:
"""Espone le ultime righe di log diagnostico ai fini diagnostici.
is_virtual = True per driver di loopback noti (BlackHole, Soundflower,
Loopback, Audio Hijack, VB-Cable, ...)
Su macOS contiene messaggi del backend AVCaptureSession + stderr di
ffmpeg durante la conversione CAF->MP3. Su Windows contiene lo
stderr di ffmpeg per la registrazione DirectShow.
"""
ffmpeg = find_ffmpeg()
if not ffmpeg:
return []
with _stderr_lock:
return list(_stderr_buf[-max_lines:])
if _IS_MAC:
return _list_macos(ffmpeg)
if _IS_WIN:
return _list_windows(ffmpeg)
return []
def _log_diag(line: str) -> None:
with _stderr_lock:
_stderr_buf.append(line)
if len(_stderr_buf) > 200:
del _stderr_buf[0]
_VIRTUAL_KEYWORDS = (
@@ -71,54 +69,44 @@ def _is_virtual(name: str) -> bool:
return any(k in n for k in _VIRTUAL_KEYWORDS)
def _list_macos(ffmpeg: str) -> list[dict]:
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
L'output va su stderr.
# ============================================================
# Lista dispositivi
# ============================================================
def list_input_devices() -> list[dict]:
"""Ritorna la lista dei dispositivi audio di input.
IMPORTANTE: come 'id' usiamo il NOME del device (non l'index numerico).
AVFoundation rinumera silenziosamente i device quando cambia il set di
hardware collegato (es. iPhone in continuita', Multi-Output Device, ecc).
Il nome e' invece stabile; ffmpeg AVFoundation accetta sia ':<index>'
sia ':<name>' come specifica del device input.
Ogni voce: {"id": "...", "name": "...", "is_virtual": bool}.
Su macOS l'`id` e' l'uniqueID di AVCaptureDevice (stabile).
Su Windows e' il nome del device (richiesto da DirectShow).
"""
if _IS_MAC:
return _list_macos_avf()
if _IS_WIN:
ffmpeg = find_ffmpeg()
return _list_windows(ffmpeg) if ffmpeg else []
return []
def _list_macos_avf() -> list[dict]:
"""Enumera input audio via AVFoundation (no ffmpeg subprocess)."""
try:
proc = subprocess.run(
[ffmpeg, "-hide_banner", "-f", "avfoundation",
"-list_devices", "true", "-i", ""],
capture_output=True, text=True, timeout=10,
**subprocess_flags(),
)
except Exception:
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
except ImportError as e:
_log_diag(f"[avf] AVFoundation non disponibile: {e}")
return []
devices: list[dict] = []
in_audio = False
audio_section_re = re.compile(r"AVFoundation\s+audio\s+devices", re.IGNORECASE)
other_section_re = re.compile(r"AVFoundation\s+(?:video)\s+devices", re.IGNORECASE)
line_re = re.compile(r"\[(\d+)\]\s+(.+?)\s*$")
for raw in proc.stderr.splitlines():
line = raw.strip()
if audio_section_re.search(line):
in_audio = True
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
try:
name = str(d.localizedName())
uid = str(d.uniqueID())
except Exception:
continue
if other_section_re.search(line):
in_audio = False
continue
if not in_audio:
continue
clean = re.sub(r"^\[AVFoundation[^\]]*\]\s*", "", line)
m = line_re.match(clean)
if not m:
continue
idx, name = m.group(1), m.group(2).strip()
devices.append({
"id": name, # nome stabile (vedi docstring)
"index": idx, # solo informativo per debug
"id": uid,
"name": name,
"is_virtual": _is_virtual(name),
})
return devices
@@ -162,7 +150,6 @@ def _list_windows(ffmpeg: str) -> list[dict]:
name = mn.group(1)
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
# Dedup preservando l'ordine
seen = set()
out = []
for d in devices:
@@ -174,130 +161,295 @@ def _list_windows(ffmpeg: str) -> list[dict]:
# ============================================================
# Registrazione
# Permesso microfono (macOS)
# ============================================================
def _build_record_cmd(ffmpeg: str, device_id: str, output_path: str,
bitrate: str) -> list[str]:
"""Comando ffmpeg per registrare su file MP3."""
if _IS_MAC:
return [
ffmpeg, "-hide_banner", "-y",
"-f", "avfoundation",
"-i", f":{device_id}",
"-acodec", "libmp3lame",
"-b:a", bitrate,
"-vn",
output_path,
]
if _IS_WIN:
return [
ffmpeg, "-hide_banner", "-y",
"-f", "dshow",
"-i", f"audio={device_id}",
"-acodec", "libmp3lame",
"-b:a", bitrate,
"-vn",
output_path,
]
raise RuntimeError("Sistema operativo non supportato per la registrazione")
def _ensure_macos_mic_permission() -> tuple[bool, str]:
"""Verifica/richiede il permesso microfono (TCC) per il processo corrente.
def start_recording(
device_id: str,
output_path: str,
bitrate: str = "320k",
progress_callback: Optional[Callable] = None,
) -> dict:
"""Avvia una registrazione in un thread.
Ritorna {ok, error?, output_path?}.
progress_callback(status, payload) viene chiamato con:
("started", {"output_path": ...})
("tick", {"seconds": int})
("stopped", {"output_path": ..., "seconds": int})
("error", {"error": "..."})
Ritorna (granted, reason).
"""
global _current_proc, _recording_thread
try:
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
except ImportError as e:
return False, f"AVFoundation non disponibile: {e}"
if is_recording():
return {"ok": False, "error": "Una registrazione e gia in corso"}
# 0=notDetermined, 1=restricted, 2=denied, 3=authorized
status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)
if status == 3:
return True, "authorized"
if status in (1, 2):
return False, (
"Permesso microfono negato.\n"
"Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
"Attiva MusicTools e riavvia l'app."
)
# notDetermined -> chiedi il prompt in modo sincrono.
ev = threading.Event()
granted = [False]
def cb(ok):
granted[0] = bool(ok)
ev.set()
AVCaptureDevice.requestAccessForMediaType_completionHandler_(AVMediaTypeAudio, cb)
# PyObjC pumpa il run loop automaticamente in attesa del callback.
ev.wait(60)
if not granted[0]:
return False, "Permesso microfono negato dall'utente."
return True, "granted"
# ============================================================
# Registrazione macOS via AVCaptureSession
# ============================================================
def _start_recording_macos(device_id: str, output_path: str, bitrate: str,
progress_callback: Optional[Callable]) -> dict:
from AVFoundation import (
AVCaptureSession, AVCaptureDevice, AVCaptureDeviceInput,
AVCaptureAudioFileOutput, AVMediaTypeAudio,
)
from Foundation import NSURL, NSObject
ok, reason = _ensure_macos_mic_permission()
if not ok:
return {"ok": False, "error": reason}
# Trova il device: prima per uniqueID, poi per nome (fallback)
dev = AVCaptureDevice.deviceWithUniqueID_(device_id)
if dev is None:
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
if str(d.localizedName()) == device_id:
dev = d
break
if dev is None:
return {"ok": False, "error": "Dispositivo audio non trovato."}
session = AVCaptureSession.alloc().init()
inp, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(dev, None)
if err is not None:
return {"ok": False, "error": f"Errore input: {err.localizedDescription()}"}
if not session.canAddInput_(inp):
return {"ok": False, "error": "Impossibile aggiungere il device alla session."}
session.addInput_(inp)
out = AVCaptureAudioFileOutput.alloc().init()
if not session.canAddOutput_(out):
return {"ok": False, "error": "Impossibile aggiungere l'output alla session."}
session.addOutput_(out)
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
caf_path = out_path.with_suffix(".caf")
# Delegate: aspetta che lo stop produca il file finale
class _Delegate(NSObject):
def captureOutput_didStartRecordingToOutputFileAtURL_fromConnections_(
self, output, url, conns):
self.started = True
def captureOutput_didFinishRecordingToOutputFileAtURL_fromConnections_error_(
self, output, url, conns, err):
self.done = True
self.err = err.localizedDescription() if err is not None else None
delegate = _Delegate.alloc().init()
delegate.started = False
delegate.done = False
delegate.err = None
session.startRunning()
out.startRecordingToOutputFileURL_outputFileType_recordingDelegate_(
NSURL.fileURLWithPath_(str(caf_path)),
"com.apple.coreaudio-format",
delegate,
)
with _recording_lock:
_recording_state.update({
"active": True,
"session": session,
"output": out,
"delegate": delegate,
"caf_path": str(caf_path),
"mp3_path": str(out_path),
"bitrate": bitrate,
"started_at": time.monotonic(),
"progress_cb": progress_callback,
})
if progress_callback:
progress_callback("started", {"output_path": str(out_path)})
# Tick thread per emettere "seconds" elapsed (la UI lo mostra)
threading.Thread(target=_tick_worker, daemon=True).start()
_log_diag(f"[avf] Recording started -> {caf_path}")
return {"ok": True, "output_path": str(out_path)}
def _tick_worker():
while is_recording():
with _recording_lock:
cb = _recording_state.get("progress_cb")
started_at = _recording_state.get("started_at", time.monotonic())
elapsed = int(time.monotonic() - started_at)
if cb:
cb("tick", {"seconds": elapsed})
time.sleep(1.0)
def _stop_recording_macos() -> dict:
with _recording_lock:
if not _recording_state.get("active"):
return {"ok": True}
state = dict(_recording_state)
_recording_state["active"] = False
session = state["session"]
out = state["output"]
delegate = state["delegate"]
caf_path = Path(state["caf_path"])
mp3_path = Path(state["mp3_path"])
bitrate = state["bitrate"]
cb = state["progress_cb"]
started_at = state["started_at"]
# Stop recording (chiama il delegate didFinish dopo aver finalizzato il file)
out.stopRecording()
# Aspetta il completamento del file
deadline = time.time() + 8
while not getattr(delegate, "done", False) and time.time() < deadline:
time.sleep(0.1)
session.stopRunning()
if getattr(delegate, "err", None):
_log_diag(f"[avf] delegate error: {delegate.err}")
seconds = int(time.monotonic() - started_at)
# Conversione CAF -> MP3 via ffmpeg (NO microfono: niente TCC)
if caf_path.exists():
ffmpeg = find_ffmpeg()
if ffmpeg:
try:
proc = subprocess.run(
[ffmpeg, "-hide_banner", "-y", "-i", str(caf_path),
"-acodec", "libmp3lame", "-b:a", bitrate,
"-vn", str(mp3_path)],
capture_output=True, text=True, timeout=120,
**subprocess_flags(),
)
if proc.returncode != 0:
_log_diag(f"[ffmpeg-convert] rc={proc.returncode}")
for ln in (proc.stderr or "").splitlines()[-10:]:
_log_diag(ln)
# Cleanup CAF temporaneo
try:
caf_path.unlink()
except OSError:
pass
except Exception as e:
_log_diag(f"[ffmpeg-convert] exception: {e}")
else:
_log_diag("[ffmpeg-convert] ffmpeg non trovato, lascio il file CAF.")
# Se ffmpeg non c'e', rinomina il CAF in MP3 (estensione errata
# ma l'utente ha qualcosa)
try:
caf_path.rename(mp3_path.with_suffix(".caf"))
except OSError:
pass
if cb:
if mp3_path.exists():
cb("stopped", {"output_path": str(mp3_path), "seconds": seconds})
else:
cb("error", {"error": "File registrato non trovato dopo lo stop."})
return {"ok": True}
# ============================================================
# Registrazione Windows via ffmpeg DirectShow (invariato)
# ============================================================
def _start_recording_windows(device_id: str, output_path: str, bitrate: str,
progress_callback: Optional[Callable]) -> dict:
ffmpeg = find_ffmpeg()
if not ffmpeg:
return {"ok": False, "error": "ffmpeg non trovato"}
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
cmd = [
ffmpeg, "-hide_banner", "-y",
"-f", "dshow",
"-i", f"audio={device_id}",
"-acodec", "libmp3lame",
"-b:a", bitrate,
"-vn",
str(out),
]
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
_stop_event.clear()
with _stderr_lock:
_stderr_buf.clear()
def _drain_stderr(proc):
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
del pipe e per salvare le ultime righe ai fini diagnostici."""
def _drain(proc):
try:
for raw in iter(proc.stderr.readline, b""):
if not raw:
break
line = raw.decode("utf-8", errors="ignore").rstrip()
with _stderr_lock:
_stderr_buf.append(line)
# Mantieni solo le ultime 50 righe per non sforare la RAM
if len(_stderr_buf) > 50:
del _stderr_buf[0]
_log_diag(line)
except Exception:
pass
def _worker():
global _current_proc
try:
with _proc_lock:
_current_proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=False,
**subprocess_flags(),
)
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=False,
**subprocess_flags(),
)
with _recording_lock:
_recording_state.update({
"active": True,
"proc": proc,
"output_path": str(out),
"started_at": time.monotonic(),
"progress_cb": progress_callback,
})
threading.Thread(target=_drain, args=(proc,), daemon=True).start()
drain_t = threading.Thread(target=_drain_stderr,
args=(_current_proc,), daemon=True)
drain_t.start()
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
# primi ~1.5s, segnala errore prima di emettere 'started'.
start = time.monotonic()
opened = False
for _ in range(15):
if _current_proc.poll() is not None:
if proc.poll() is not None:
break
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
with _stderr_lock:
if any("Press [q]" in l for l in _stderr_buf):
opened = True
break
time.sleep(0.1)
rc_early = _current_proc.poll()
if not opened and rc_early is not None:
# ffmpeg e' uscito prima di iniziare a registrare
with _proc_lock:
_current_proc = None
if not opened and proc.poll() is not None:
with _recording_lock:
_recording_state["active"] = False
if progress_callback:
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
progress_callback("error", {
"error": _extract_friendly_error_windows(proc.returncode),
})
return
if progress_callback:
progress_callback("started", {"output_path": str(out)})
while True:
if _stop_event.is_set():
break
rc = _current_proc.poll()
with _recording_lock:
if not _recording_state.get("active"):
break
rc = proc.poll()
if rc is not None:
break
elapsed = int(time.monotonic() - start)
@@ -305,123 +457,95 @@ def start_recording(
progress_callback("tick", {"seconds": elapsed})
time.sleep(1.0)
# Stop gracefully
if _current_proc.poll() is None:
if proc.poll() is None:
try:
_current_proc.stdin.write(b"q\n")
_current_proc.stdin.flush()
proc.stdin.write(b"q\n")
proc.stdin.flush()
except Exception:
pass
try:
_current_proc.wait(timeout=5)
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
_current_proc.terminate()
proc.terminate()
try:
_current_proc.wait(timeout=3)
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
_current_proc.kill()
proc.kill()
rc = _current_proc.returncode
seconds = int(time.monotonic() - start)
with _proc_lock:
_current_proc = None
with _recording_lock:
_recording_state["active"] = False
if progress_callback:
if rc == 0 or out.exists():
if proc.returncode == 0 or out.exists():
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
else:
progress_callback("error", {"error": _extract_friendly_error(rc)})
progress_callback("error", {
"error": _extract_friendly_error_windows(proc.returncode),
})
except Exception as e:
with _proc_lock:
_current_proc = None
with _recording_lock:
_recording_state["active"] = False
if progress_callback:
progress_callback("error", {"error": str(e)})
_recording_thread = threading.Thread(target=_worker, daemon=True)
_recording_thread.start()
progress_callback("error", {"error": f"{type(e).__name__}: {e}"})
threading.Thread(target=_worker, daemon=True).start()
return {"ok": True, "output_path": str(out)}
def _extract_friendly_error(rc: int) -> str:
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
Riconosce i casi tipici: permessi microfono, device occupato,
device non disponibile, clock drift di BlackHole, ecc.
Fallback: se non riconosce il pattern, ritorna le ultime righe
significative dello stderr cosi' l'utente puo' diagnosticare
o aprire un ticket.
"""
def _extract_friendly_error_windows(rc: int) -> str:
with _stderr_lock:
lines = list(_stderr_buf)
text = "\n".join(lines).lower()
is_mac = sys.platform == "darwin"
# ---- Errori specifici (priorita' alta) ----
if "input/output error" in text or "errno 22" in text:
return (
"Dispositivo audio non pronto (BlackHole o scheda virtuale). "
"Spesso si risolve cosi':\n"
" 1) Smetti la registrazione e riprova fra 5 secondi\n"
" 2) Se persiste, da Terminale: 'sudo killall coreaudiod'\n"
" 3) Verifica che nessun'altra app stia gia' registrando il device"
)
if "device not configured" in text or "device not available" in text:
return "Dispositivo non configurato. Premi 'Aggiorna' e riseleziona."
if "permission" in text or "not authorized" in text or "not permitted" in text \
or "tcc" in text or "denied" in text:
return _permission_message()
if "no such device" in text or "no such audio device" in text or "invalid device" in text:
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona dalla lista."
# ---- Macro per rc=-6 (SIGABRT) su macOS ----
# Su macOS rc=-6 e' quasi sempre il sintomo di un crash di ffmpeg
# dovuto al permesso Microfono mancante. La parola 'abort' non
# sempre compare nello stderr di ffmpeg recenti.
if rc == -6 and is_mac:
return _permission_message()
# ---- Fallback: cerca le ultime righe significative ----
if "no such device" in text or "could not find" in text:
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona."
if "permission" in text or "denied" in text:
return "Permesso microfono mancante. Impostazioni Windows -> Privacy -> Microfono."
for line in reversed(lines):
l = line.strip()
if not l:
continue
low = l.lower()
# Riga AVFoundation/dshow specifica (es. "[avfoundation @ 0x...] Could not...")
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
if "error" in low or "fail" in low or "denied" in low:
return l
if "error" in low or "fail" in low or "denied" in low or "cannot" in low:
return l
# Ultima spiaggia: rc + ultime 3 righe di stderr per debug
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
def _permission_message() -> str:
"""Messaggio standardizzato per il problema permesso Microfono macOS."""
return (
"Permesso Microfono mancante. Su macOS:\n"
" Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
" Abilita 'Terminal' (se lanci l'app da terminale) o 'MusicTools'\n"
" (se usi l'app installata). Poi riavvia l'app."
)
def _stop_recording_windows() -> dict:
with _recording_lock:
if not _recording_state.get("active"):
return {"ok": True}
# Il worker thread vede 'active'=False e termina ffmpeg con 'q'.
_recording_state["active"] = False
return {"ok": True}
def get_last_stderr(max_lines: int = 50) -> list[str]:
"""Espone le ultime righe di stderr ai fini diagnostici.
Chiamato dal bridge per la UI 'Mostra log tecnici'."""
# ============================================================
# API pubblica
# ============================================================
def start_recording(
device_id: str,
output_path: str,
bitrate: str = "320k",
progress_callback: Optional[Callable] = None,
) -> dict:
if is_recording():
return {"ok": False, "error": "Una registrazione e gia in corso"}
with _stderr_lock:
return list(_stderr_buf[-max_lines:])
_stderr_buf.clear()
if _IS_MAC:
return _start_recording_macos(device_id, output_path, bitrate, progress_callback)
if _IS_WIN:
return _start_recording_windows(device_id, output_path, bitrate, progress_callback)
return {"ok": False, "error": "Sistema operativo non supportato per la registrazione"}
def stop_recording() -> dict:
"""Ferma la registrazione corrente (se attiva)."""
_stop_event.set()
if _IS_MAC:
return _stop_recording_macos()
if _IS_WIN:
return _stop_recording_windows()
return {"ok": True}
+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}
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
@@ -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
+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>
+7
View File
@@ -5,5 +5,12 @@ mutagen>=1.47.0
pyobjc-core>=10.0 ; sys_platform == "darwin"
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
pythonnet==3.0.5 ; sys_platform == "win32"
clr-loader==0.2.7.post0 ; sys_platform == "win32"
curl_cffi>=0.9.0
# --- dev only ---
pytest>=8.0.0
responses>=0.25.0
freezegun>=1.4.0
+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")
+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;
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>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="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
@@ -849,6 +853,63 @@
</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-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>
</div>
+293
View File
@@ -501,6 +501,9 @@ async function init() {
$("#recPathDisplay").classList.remove("empty");
}
await refreshRecDevices();
// Beatport tab — popola generi, ripristina ultimo genere, aggancia handler
await BeatportUI.init();
}
async function refreshRecDevices() {
@@ -1170,6 +1173,296 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => {
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
});
// ============================================================
// BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica
// (il download riusa start_tracks_download, quindi passa dagli stessi
// canali "download:progress" / "download:done" / log view="download")
// ============================================================
const BeatportUI = (function () {
let genres = [];
let currentTracks = [];
let existing = [];
let currentGenreName = "";
let currentGenreSlug = "";
let downloading = false;
function fmtDur(sec) {
const n = Number(sec) || 0;
const m = Math.floor(n / 60);
const s = Math.floor(n % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function safeGenreFolder(name) {
return String(name || "").replace(/[\/\\]/g, "_").trim();
}
function populateSelect() {
const sel = $("#beatport-genre");
if (!sel) return;
sel.innerHTML = "";
for (const g of genres) {
const opt = document.createElement("option");
opt.value = g.slug;
opt.textContent = g.name;
sel.appendChild(opt);
}
}
function updateOutputInfo() {
const info = $("#beatport-output-info");
if (!info) return;
const root = (state.config && state.config.output_dir) || "";
if (!root) {
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
info.className = "beatport-output-info warn";
return;
}
const folder = safeGenreFolder(currentGenreName);
info.textContent = folder
? `Destinazione: ${root}/Beatport/${folder}`
: `Destinazione: ${root}/Beatport`;
info.className = "beatport-output-info";
}
function setStatus(text, kind) {
const el = $("#beatport-status");
if (!el) return;
el.textContent = text || "";
el.className = "beatport-status" + (kind ? " " + kind : "");
}
async function loadChart(forceRefresh) {
const sel = $("#beatport-genre");
const slug = sel && sel.value;
if (!slug) return;
const genreName = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || slug;
currentGenreSlug = slug;
currentGenreName = genreName;
setStatus(forceRefresh ? "Ricarico Top 100 (bypass cache)…" : "Caricamento Top 100…", "loading");
$("#beatport-table").hidden = true;
$("#beatport-toolbar").hidden = true;
$("#beatport-table").querySelector("tbody").innerHTML = "";
let res;
try {
res = await window.pywebview.api.beatport_fetch_chart(slug, !!forceRefresh);
} catch (e) {
setStatus("Errore: " + ((e && e.message) || e), "error");
return;
}
if (!res || !res.ok) {
const code = res && res.error;
let msg = (res && res.message) || "Errore sconosciuto";
if (code === "invalid_genre") msg = "Genere non valido";
else if (code === "unreachable") msg = "Beatport non raggiungibile — riprova più tardi";
else if (code === "parse") msg = "Impossibile leggere i dati (schema pagina cambiato?)";
setStatus(msg, "error");
return;
}
currentTracks = res.tracks || [];
try {
existing = await window.pywebview.api.beatport_check_existing(currentTracks, currentGenreName);
} catch (_e) {
existing = currentTracks.map(() => false);
}
setStatus(`${currentTracks.length} brani caricati`, "ok");
renderTable();
}
function renderTable() {
const table = $("#beatport-table");
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
currentTracks.forEach((t, i) => {
const tr = document.createElement("tr");
if (existing[i]) tr.classList.add("already-downloaded");
const tdCheck = document.createElement("td");
tdCheck.className = "col-check";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.dataset.idx = String(i);
cb.checked = !existing[i];
cb.addEventListener("change", updateSelectionCount);
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
const 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
// ============================================================