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.
14 KiB
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:
- Scegliere un genere musicale da un elenco di ~30 generi Beatport
- Caricare la classifica Top 100 ufficiale di quel genere dal sito Beatport
- Visualizzare i 100 brani in tabella con checkbox per scegliere quali scaricare
- 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:
GET https://www.beatport.com/genre/<slug>/<id>/top-100con User-Agent browserre.search(r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>', html)per estrarre il JSONjson.loads+ walk fino ai 100 track object- 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)
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
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: usasearch_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 esistentedownload_from_spotify_urlin 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 campobeatport_last_genre: strai DEFAULTS.
Frontend
Nuova tab in webui/index.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)
- Avvia MusicTools, apri tab Beatport
- Seleziona "Melodic House & Techno" → click "Carica Top 100" → tabella si popola in <3s
- Deseleziona 90 brani, lascia i primi 10 → click "Scarica selezionati (10)"
- Verifica file .mp3 in
{output_dir}/Beatport/Melodic House & Techno/, metadata Spotify complete - Ricarica lo stesso genere → cache hit (nessuna nuova request osservabile in dev tools)
- Cambia genere → nuova fetch
- Interrompi a metà download → si ferma pulito dopo il brano corrente
- Riavvia app → dropdown ricorda l'ultimo genere
Rollout
- Branch
feat/beatport-charts(opzionale — la feature è additive, zero rischi per il resto) - Bump
core/config.pyVERSION →v1.8.0 - 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.
- Commit + tag
v1.8.0→ CI builda macOS + Windows come da flusso standard - Zero cambi lato server (
~/api/), zero migration DB
Struttura file impattati
Nuovi:
core/beatport.pytests/test_beatport.pytests/fixtures/beatport_melodic_top100.html
Modificati:
main.py— 4 nuovi metodiApi.beatport_*core/config.py— VERSION bump + campobeatport_last_genrein DEFAULTScore/spotify_client.py— esponesearch_track()come funzione pubblica se non lo è giàwebui/index.html— nuovo tab button + sectionwebui/js/app.js— funzionibeatport*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 conre.search+json.loads, no BeautifulSoup necessario). Solo dev dep:responsesper i test HTTP mockati se non già presente