# 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 `` 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///top-100` con User-Agent browser 2. `re.search(r'', 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//.""" ... 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
``` **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: " ├─ 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=) ├─ emit log "[i/N] ✓" o "[i/N] ✗ " └─ 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-.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 `