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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#
Artista
Titolo
Durata
+
+
+
+
+
+
+ 0/0 selezionati
+
+
+
+
+
+
+```
+
+**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 `