diff --git a/docs/superpowers/specs/2026-07-14-beatport-charts-design.md b/docs/superpowers/specs/2026-07-14-beatport-charts-design.md new file mode 100644 index 0000000..cc02ce6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-beatport-charts-design.md @@ -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 `` 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 `