diff --git a/api/bridge.py b/api/bridge.py index 9a4fa95..45044b6 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -15,7 +15,7 @@ from typing import Any, Callable, Optional import requests from core.config import load_config, save_config, VERSION, LICENSE_API_URL -from core import beatport, spotify_client +from core import beatport, spotify_client, traxsource from core import license as license_mod from core.downloader import ( download_playlist, @@ -1165,6 +1165,110 @@ class Api: "subfolder": subfolder, }) + # ================================================================ + # Traxsource charts + # ================================================================ + def _traxsource_output_dir(self, out_root: str, genre_name: str) -> Path: + """Cartella dove finiscono i file Traxsource. Coerente con la + sanitizzazione di `start_tracks_download` (slash -> underscore).""" + safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip() + subfolder = f"Traxsource_{safe_genre}" if safe_genre else "Traxsource" + return Path(out_root) / subfolder + + def traxsource_genres(self) -> list: + """Lista dei generi Traxsource per il dropdown UI.""" + return traxsource.list_genres() + + def traxsource_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict: + """Fetches la Top 100 corrente per il genere. Salva l'ultimo genere in config.""" + try: + cfg = load_config() + cfg["traxsource_last_genre"] = slug + save_config(cfg) + except Exception: + pass + + try: + tracks = traxsource.fetch_top100(slug, force_refresh=force_refresh) + except ValueError as e: + return {"ok": False, "error": "invalid_genre", "message": str(e)} + except traxsource.TraxsourceUnreachableError as e: + return {"ok": False, "error": "unreachable", "message": str(e)} + except traxsource.TraxsourceParseError as e: + return {"ok": False, "error": "parse", "message": str(e)} + + return {"ok": True, "tracks": [asdict(t) for t in tracks]} + + def traxsource_check_existing(self, tracks: list, genre_name: str) -> list: + """True per ogni track già presente nella cartella output Traxsource. + Match euristico: filename (senza extension) deve contenere 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._traxsource_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 + result.append(any( + (title in stem and first_artist in stem) + for stem in existing_stems + )) + return result + + def traxsource_download_selected(self, tracks: list, genre_name: str) -> dict: + """Converte i TraxsourceTrack in tracklist compatibile con + start_tracks_download + costruisce metadata paralleli per il tagger ID3 + (album=label Traxsource, date=YYYY-MM corrente, genre=nome genere).""" + cfg = load_config() + out_root = (cfg.get("output_dir") or "").strip() + if not out_root: + return {"ok": False, "error": "Cartella output non impostata"} + + target = self._traxsource_output_dir(out_root, genre_name) + subfolder = target.name # es. "Traxsource_Tech House" + + import datetime as _dt + current_month = _dt.date.today().strftime("%Y-%m") + + converted: list = [] + metadata: list = [] + 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}) + metadata.append({ + "title": title, + "artist": artists, + "album": (t.get("label") or "").strip() or f"Traxsource Top 100 {current_month}", + "date": current_month, + "genre": genre_name, + "cover_url": (t.get("cover_url_large") or t.get("image_url") or "").strip(), + }) + + if not converted: + return {"ok": False, "error": "Nessun brano valido"} + + return self.start_tracks_download({ + "tracks": converted, + "output_dir": out_root, + "subfolder": subfolder, + "metadata": metadata, + }) + # ================================================================ # Music Search — Spotify + YouTube # ================================================================ diff --git a/core/config.py b/core/config.py index 0d9ea2e..687425b 100644 --- a/core/config.py +++ b/core/config.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -VERSION = "v1.8.6" +VERSION = "v1.9.0" APP_NAME = "MusicTools" @@ -72,6 +72,8 @@ DEFAULTS = { "theme": "dark", # ---- Beatport ---- "beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato + # ---- Traxsource ---- + "traxsource_last_genre": "tech-house", # ---- Music Search (Spotify + YouTube) ---- "spotify_search_last_query": "", "spotify_search_artist_mode": False, diff --git a/core/traxsource.py b/core/traxsource.py new file mode 100644 index 0000000..f49af28 --- /dev/null +++ b/core/traxsource.py @@ -0,0 +1,277 @@ +"""Fetch Top 100 Traxsource per genere. + +Approccio: session curl_cffi (bypass CF via cookie) + BeautifulSoup HTML scraping. +Spec: docs/superpowers/specs/2026-08-01-traxsource-charts-design.md. +""" + +from __future__ import annotations + +import re +import time +from dataclasses import dataclass + + +# Generi musicali Traxsource (slug -> (id, display_name)). +# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops", +# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto). +GENRES: dict = { + "afro-house": (27, "Afro House"), + "afro-latin-brazilian": (23, "Afro / Latin / Brazilian"), + "broken-beat-nu-jazz": (2, "Broken Beat / Nu Jazz"), + "classic-house": (12, "Classic House"), + "deep-house": (13, "Deep House"), + "drum-and-bass": (31, "Drum & Bass"), + "electro-house": (11, "Electro House"), + "electronica": (5, "Electronica"), + "garage": (29, "Garage"), + "house": (4, "House"), + "jackin-house": (15, "Jackin House"), + "leftfield": (14, "Leftfield"), + "lounge-chill-out": (1, "Lounge / Chill Out"), + "melodic-progressive-house": (19, "Melodic / Progressive House"), + "minimal-deep-tech": (16, "Minimal / Deep Tech"), + "nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"), + "pop-dance": (32, "Pop Dance"), + "r-and-b-hip-hop": (6, "R&B / Hip Hop"), + "soul-funk-disco": (3, "Soul / Funk / Disco"), + "soulful-house": (24, "Soulful House"), + "tech-house": (18, "Tech House"), + "techno": (20, "Techno"), + "world": (30, "World"), +} + + +@dataclass(frozen=True) +class TraxsourceTrack: + position: int + title: str + mix: str + artists: str + label: str + traxsource_id: int + slug: str + image_url: str = "" + cover_url_large: str = "" + + +def list_genres() -> list: + result = [ + {"slug": slug, "id": gid, "name": name} + for slug, (gid, name) in GENRES.items() + ] + result.sort(key=lambda g: g["name"].casefold()) + return result + + +class TraxsourceError(Exception): + """Base per errori Traxsource.""" + + +class TraxsourceUnreachableError(TraxsourceError): + """Rete / 5xx dopo retry.""" + + +class TraxsourceParseError(TraxsourceError): + """HTML ricevuto ma non conforme allo schema atteso.""" + + +_MIX_PAREN_RE = re.compile(r"^(.*)\s*\(([^()]+)\)\s*$") +_SIZE_RE = re.compile(r"/\d+x\d+/") + + +def _split_title_mix(full_title: str) -> tuple: + """Estrae mix dalle parentesi finali. 'Foo (Extended Mix)' -> ('Foo', 'Extended Mix'). + Se non ci sono parentesi finali, mix = ''.""" + if not full_title: + return ("", "") + m = _MIX_PAREN_RE.match(full_title.strip()) + if m: + return (m.group(1).strip(), m.group(2).strip()) + return (full_title.strip(), "") + + +def _format_artists(names: list) -> str: + """['A', 'B', 'C'] -> 'A, B & C'. Strips whitespace.""" + clean = [n.strip() for n in names if n and n.strip()] + if not clean: + return "" + if len(clean) == 1: + return clean[0] + return ", ".join(clean[:-1]) + " & " + clean[-1] + + +def _large_cover(url: str) -> str: + """Sostituisce /NxN/ nel path con /500x500/. Se pattern assente, ritorna invariato.""" + if not url: + return "" + return _SIZE_RE.sub("/500x500/", url) + + +_TOP100_LINK_RE = re.compile(r'href="(/title/\d+/top-100-[a-z0-9-]+)"') + + +def _discover_top100_url(genre_html: str) -> str: + """Estrae il path relativo della playlist Top 100 corrente dalla pagina di un genere.""" + m = _TOP100_LINK_RE.search(genre_html) + if not m: + raise TraxsourceParseError("link Top 100 non trovato nella pagina genere") + return m.group(1) + + +def _parse_tracks(html: str) -> list: + """Parsa la pagina Top 100 (title playlist) e ritorna list[TraxsourceTrack]. + + Selettori (verificati su fixture tech-house 2026-07): + row = div.trk-row.play-trk (data-trid=) + position = div.tnum (inside div.tnum-pos) + title = div.trk-cell.title a[href^="/track/"] + version = span.version (contiene child span.duration da rimuovere) + artists = a.com-artists (uno o piu) + label = div.trk-cell.label a + cover img = div.trk-cell.thumb img (src /scripts/image.php/52x52/...) + """ + # Lazy import — bs4 non e' hard-dep del modulo (importato solo quando serve). + from bs4 import BeautifulSoup + + soup = BeautifulSoup(html, "html.parser") + rows = soup.select("div.trk-row.play-trk") + if not rows: + raise TraxsourceParseError("nessuna track (div.trk-row.play-trk) trovata") + + out: list = [] + for i, row in enumerate(rows, 1): + try: + trid = int(row.get("data-trid") or 0) + + pos_el = row.select_one("div.tnum") + position = i # fallback su enumerate se pos manca / non e' un numero + if pos_el: + pos_txt = pos_el.get_text(strip=True) + if pos_txt.isdigit(): + position = int(pos_txt) + + title_a = row.select_one('div.trk-cell.title a[href^="/track/"]') + if not title_a: + continue + title = title_a.get_text(strip=True) + href = title_a.get("href") or "" + slug = href.rsplit("/", 1)[-1] + + # Mix version: contenuto di span.version, escluso span.duration + mix = "" + version_el = row.select_one("span.version") + if version_el: + dur_el = version_el.select_one("span.duration") + if dur_el: + dur_el.extract() + mix = version_el.get_text(strip=True) + + artist_names = [a.get_text(strip=True) for a in row.select("a.com-artists")] + artists = _format_artists(artist_names) + + label_a = row.select_one("div.trk-cell.label a") + label = label_a.get_text(strip=True) if label_a else "" + + img = row.select_one('div.trk-cell.thumb img[src*="/scripts/image.php/"]') + image_url = (img.get("src") or "") if img else "" + cover_large = _large_cover(image_url) + + out.append(TraxsourceTrack( + position=position, + title=title, + mix=mix, + artists=artists, + label=label, + traxsource_id=trid, + slug=slug, + image_url=image_url, + cover_url_large=cover_large, + )) + except Exception as e: + raise TraxsourceParseError(f"errore parse track[{i}]: {e}") from e + return out + + +# --- fetch_top100 con session curl_cffi + retry + cache ------------------ + +_IMPERSONATE = "chrome131" +_REQUEST_TIMEOUT = 15 +_MAX_ATTEMPTS = 3 +_BACKOFF_SEC = [1, 3] +_CACHE_TTL_SEC = 15 * 60 + +_cache: dict = {} +_session_singleton = None + + +def _session(): + """Ritorna la Session curl_cffi singleton, preriscaldata con GET a /.""" + global _session_singleton + if _session_singleton is None: + from curl_cffi import requests as _cffi + _session_singleton = _cffi.Session(impersonate=_IMPERSONATE) + try: + _session_singleton.get("https://www.traxsource.com/", timeout=_REQUEST_TIMEOUT) + except Exception: + pass # cookie CF possono arrivare comunque + return _session_singleton + + +def _do_get(session, url: str) -> str: + """GET con retry + backoff. Include Referer per pagine interne.""" + last_exc = None + for attempt in range(_MAX_ATTEMPTS): + try: + resp = session.get( + url, + timeout=_REQUEST_TIMEOUT, + headers={"Referer": "https://www.traxsource.com/"}, + ) + 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 TraxsourceUnreachableError( + f"Traxsource irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}" + ) + + +def fetch_top100(slug: str, force_refresh: bool = False) -> list: + """Fetches Top 100 corrente per il genere. + + 1. Verifica slug in GENRES + 2. Fetch pagina genre -> _discover_top100_url + 3. Fetch pagina Top 100 -> _parse_tracks + 4. Cache 15 min + + Raises: + ValueError: slug non in GENRES + TraxsourceUnreachableError: rete/5xx dopo retry + TraxsourceParseError: HTML non conforme + """ + 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] + + gid, _name = GENRES[slug] + sess = _session() + + genre_url = f"https://www.traxsource.com/genre/{gid}/{slug}" + genre_html = _do_get(sess, genre_url) + top100_path = _discover_top100_url(genre_html) + + top100_url = "https://www.traxsource.com" + top100_path + top100_html = _do_get(sess, top100_url) + tracks = _parse_tracks(top100_html) + + _cache[slug] = (now, tracks) + return tracks diff --git a/docs/superpowers/plans/2026-08-01-traxsource-charts.md b/docs/superpowers/plans/2026-08-01-traxsource-charts.md new file mode 100644 index 0000000..f806fcb --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-traxsource-charts.md @@ -0,0 +1,1043 @@ +# Traxsource Charts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) o superpowers:executing-plans per eseguire task-by-task. + +**Goal:** Nuova tab "Traxsource ▲" in sidebar che carica la Top 100 mensile per genere e scarica i brani riusando pipeline Spotify search → yt-dlp → tag ID3. + +**Architecture:** Parallela a Beatport. Bypass CF via `curl_cffi.Session` (session-based, non solo TLS impersonation). HTML scraping con BeautifulSoup su class name stabili. Doppio fetch: `/genre//` → estrai link `/title//top-100-...` → parse 100 track. Cache 15 min in-memory. + +**Tech Stack:** Python 3.8+ con `from __future__ import annotations`, `curl_cffi` (già in deps), `beautifulsoup4` (nuova dep). Frontend vanilla JS, riusa classi CSS `.beatport-*`. + +**Spec:** `docs/superpowers/specs/2026-08-01-traxsource-charts-design.md` + +**Reference implementation:** `core/beatport.py`, `api/bridge.py::beatport_*` metodi, `webui/js/app.js::BeatportUI`. Il pattern è identico — replica con adattamenti al parser HTML. + +--- + +## Note operative + +- Branch: **`feat/traxsource-charts`** (già creato, HEAD dopo spec) +- Git author: `git -c user.email=info@djluza.com commit ...` sempre +- Python 3.8: `from __future__ import annotations` in ogni nuovo file +- Non toccare `server/`, `landing/` +- Test infra pytest già presente (61+ test verdi baseline) + +--- + +## Task 1: Setup deps + fixture HTML + genres refresh + +**Files:** +- Modify: `requirements.txt` +- Create: `tests/fixtures/traxsource_tech_house_genre.html` +- Create: `tests/fixtures/traxsource_tech_house_top100.html` +- Create: `scripts/refresh_traxsource_genres.py` + +- [ ] **Step 1: Aggiungi `beautifulsoup4` a requirements.txt** + +Prima del blocco `# --- dev only ---` in `requirements.txt`: +``` +beautifulsoup4>=4.12.0 +``` + +Verifica: `python3 -m pip install -r requirements.txt` non deve fallire. + +- [ ] **Step 2: Scarica fixture pagina genre (per test discovery URL)** + +```bash +python3 -c " +from curl_cffi import requests +s = requests.Session(impersonate='chrome131') +s.get('https://www.traxsource.com/', timeout=15) +r = s.get('https://www.traxsource.com/genre/18/tech-house', timeout=15, + headers={'Referer': 'https://www.traxsource.com/'}) +assert r.status_code == 200 and 'top-100' in r.text.lower(), f'bad genre page (HTTP {r.status_code})' +open('tests/fixtures/traxsource_tech_house_genre.html', 'w').write(r.text) +print('saved genre:', len(r.text), 'bytes') +" +``` + +Se il file è < 100KB o non contiene `top-100`: STOP + report BLOCKED con status HTTP. + +- [ ] **Step 3: Scarica fixture pagina Top 100 (~400KB)** + +Prima estrai l'URL Top 100 corrente dal genre fixture: +```bash +python3 -c " +import re +html = open('tests/fixtures/traxsource_tech_house_genre.html').read() +m = re.search(r'href=\"(/title/\d+/top-100-[a-z0-9-]+)\"', html) +assert m, 'Top 100 URL non trovato nella genre page' +print(m.group(1)) +" > /tmp/tx_top100_url.txt +cat /tmp/tx_top100_url.txt +``` + +Poi scarica quella URL: +```bash +python3 -c " +from curl_cffi import requests +url_path = open('/tmp/tx_top100_url.txt').read().strip() +s = requests.Session(impersonate='chrome131') +s.get('https://www.traxsource.com/', timeout=15) +r = s.get('https://www.traxsource.com' + url_path, timeout=15, + headers={'Referer': 'https://www.traxsource.com/'}) +assert r.status_code == 200, f'HTTP {r.status_code}' +n_tracks = r.text.count('data-trid=') +assert n_tracks >= 100, f'solo {n_tracks} track (attesi 100)' +open('tests/fixtures/traxsource_tech_house_top100.html', 'w').write(r.text) +print('saved top100:', len(r.text), 'bytes,', n_tracks, 'track markers') +" +``` + +- [ ] **Step 4: Verifica shape del track markup nella fixture** + +```bash +python3 -c " +import re +html = open('tests/fixtures/traxsource_tech_house_top100.html').read() +# Trova primo blocco track +m = re.search(r'
]*>.{0,1500}', html, re.DOTALL) +print(m.group(0)[:1000] if m else 'NO MATCH') +" +``` + +Aspettato: markup con ``, ``, ``, `
`, ``. + +Se qualche selettore è cambiato, ANNOTA e adatta i Task 4-5 di conseguenza. + +- [ ] **Step 5: Script refresh genres** + +Crea `scripts/refresh_traxsource_genres.py`: + +```python +"""Estrae la lista dei generi Traxsource dalla pagina /top. +Uso: python3 scripts/refresh_traxsource_genres.py > /tmp/tx_genres.txt +Poi copia manualmente in core/traxsource.py::GENRES.""" + +from __future__ import annotations + +import re +import sys +from curl_cffi import requests + + +def main() -> int: + s = requests.Session(impersonate="chrome131") + s.get("https://www.traxsource.com/", timeout=15) + r = s.get("https://www.traxsource.com/top", timeout=15, + headers={"Referer": "https://www.traxsource.com/"}) + if r.status_code != 200: + print(f"HTTP {r.status_code}", file=sys.stderr) + return 1 + # href="/genre//" + hits = re.findall(r'href="/genre/(\d+)/([a-z0-9-]+)"', r.text) + seen = set() + for gid, slug in hits: + if slug in seen: + continue + seen.add(slug) + # Display name = slug con capitalizzazione euristica (l'utente lo raffina a mano) + name = " ".join(w.capitalize() for w in slug.replace("-", " ").split()) + print(f' "{slug}": ({gid}, "{name}"),') + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +Esegui: +```bash +mkdir -p scripts +python3 scripts/refresh_traxsource_genres.py > /tmp/tx_genres.txt +head -30 /tmp/tx_genres.txt +wc -l /tmp/tx_genres.txt +``` + +Attesi: 15-25 generi. Salva l'output — serve nel Task 2. + +- [ ] **Step 6: Commit** + +```bash +git add requirements.txt scripts/refresh_traxsource_genres.py tests/fixtures/traxsource_*.html +git -c user.email=info@djluza.com commit -m "traxsource: fixture HTML + bs4 dep + script refresh generi" +``` + +Report finale: dimensione fixture, numero track markers nel Top 100, generi estratti (sample), primi 800 char del track markup. + +--- + +## Task 2: GENRES map + TraxsourceTrack + list_genres — TDD + +**Files:** +- Create: `core/traxsource.py` +- Create: `tests/test_traxsource.py` + +- [ ] **Step 1: Test in `tests/test_traxsource.py`** + +```python +"""Test per core.traxsource.""" + +from __future__ import annotations + +import pytest + +from core import traxsource + + +class TestListGenres: + def test_returns_list_of_dicts(self): + result = traxsource.list_genres() + assert isinstance(result, list) + assert len(result) >= 10 + 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 = traxsource.list_genres() + names = [g["name"] for g in result] + assert names == sorted(names, key=str.casefold) + + def test_tech_house_present(self): + result = traxsource.list_genres() + slugs = [g["slug"] for g in result] + assert "tech-house" in slugs + + +class TestTraxsourceTrack: + def test_frozen(self): + t = traxsource.TraxsourceTrack( + position=1, title="X", mix="Y", artists="A", label="L", + traxsource_id=1, slug="x", + ) + with pytest.raises(Exception): + t.title = "Z" + + def test_defaults(self): + t = traxsource.TraxsourceTrack( + position=1, title="X", mix="", artists="A", label="", + traxsource_id=1, slug="x", + ) + assert t.image_url == "" + assert t.cover_url_large == "" +``` + +- [ ] **Step 2: Verifica fail** +``` +python3 -m pytest tests/test_traxsource.py -v +``` +Expected: FAIL con `ModuleNotFoundError`. + +- [ ] **Step 3: Implementa `core/traxsource.py` minimo** + +```python +"""Fetch Top 100 Traxsource per genere. + +Approccio: session curl_cffi (bypass CF via cookie) + BeautifulSoup HTML scraping. +Spec: docs/superpowers/specs/2026-08-01-traxsource-charts-design.md. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +# Sostituire il blocco sottostante con l'output di /tmp/tx_genres.txt +GENRES: dict = { + "afro-house": (33, "Afro House"), + "amapiano": (37, "Amapiano"), + "classic-house": (12, "Classic House"), + "deep-house": (13, "Deep House"), + "electronica": (5, "Electronica"), + "electro-house": (11, "Electro House"), + "funky-jackin-groovy": (15, "Funky / Jackin / Groovy"), + "house": (1, "House"), + "melodic-house-techno-progressive-house": (34, "Melodic House / Techno / Progressive House"), + "minimal-deep-tech": (27, "Minimal / Deep Tech"), + "progressive-house": (28, "Progressive House"), + "soulful-house": (24, "Soulful House"), + "tech-house": (18, "Tech House"), + "techno-peak-time-driving": (26, "Techno (Peak Time / Driving)"), + "techno-raw-deep-hypnotic": (32, "Techno (Raw / Deep / Hypnotic)"), + "trance": (30, "Trance"), +} + + +@dataclass(frozen=True) +class TraxsourceTrack: + position: int + title: str + mix: str + artists: str + label: str + traxsource_id: int + slug: str + image_url: str = "" + cover_url_large: str = "" + + +def list_genres() -> list: + result = [ + {"slug": slug, "id": gid, "name": name} + for slug, (gid, name) in GENRES.items() + ] + result.sort(key=lambda g: g["name"].casefold()) + return result +``` + +**Se hai l'output completo /tmp/tx_genres.txt:** sostituisci il blocco `GENRES = {...}` con la lista completa dallo script. + +- [ ] **Step 4: Verifica PASS** +``` +python3 -m pytest tests/test_traxsource.py -v +``` +Expected: 5 test PASS. + +- [ ] **Step 5: Commit** +```bash +git add core/traxsource.py tests/test_traxsource.py +git -c user.email=info@djluza.com commit -m "traxsource: GENRES + TraxsourceTrack + list_genres" +``` + +--- + +## Task 3: Helper parser (_split_title_mix, _format_artists, _large_cover) + exceptions — TDD + +**Files:** +- Modify: `core/traxsource.py` +- Modify: `tests/test_traxsource.py` + +- [ ] **Step 1: Test in fondo a `tests/test_traxsource.py`** + +```python +class TestSplitTitleMix: + def test_with_parens(self): + assert traxsource._split_title_mix("Foo (Extended Mix)") == ("Foo", "Extended Mix") + + def test_without_parens(self): + assert traxsource._split_title_mix("Foo") == ("Foo", "") + + def test_multiple_parens_takes_last(self): + # es. "Foo (feat. Bar) (Original Mix)" → mix = "Original Mix" + assert traxsource._split_title_mix("Foo (feat. Bar) (Original Mix)") == ("Foo (feat. Bar)", "Original Mix") + + def test_empty(self): + assert traxsource._split_title_mix("") == ("", "") + + +class TestFormatArtists: + def test_single(self): + assert traxsource._format_artists(["Kapuchon"]) == "Kapuchon" + + def test_two(self): + assert traxsource._format_artists(["A", "B"]) == "A & B" + + def test_three(self): + assert traxsource._format_artists(["A", "B", "C"]) == "A, B & C" + + def test_empty(self): + assert traxsource._format_artists([]) == "" + + def test_strips_whitespace(self): + assert traxsource._format_artists([" A ", " B "]) == "A & B" + + +class TestLargeCover: + def test_substitutes_52x52_with_500x500(self): + url = "https://www.traxsource.com/scripts/image.php/52x52/abc.jpg" + assert traxsource._large_cover(url) == "https://www.traxsource.com/scripts/image.php/500x500/abc.jpg" + + def test_no_change_when_no_pattern(self): + assert traxsource._large_cover("https://example.com/x.jpg") == "https://example.com/x.jpg" + + def test_empty(self): + assert traxsource._large_cover("") == "" + + +class TestExceptions: + def test_exceptions_are_subclasses(self): + assert issubclass(traxsource.TraxsourceUnreachableError, traxsource.TraxsourceError) + assert issubclass(traxsource.TraxsourceParseError, traxsource.TraxsourceError) +``` + +- [ ] **Step 2: Verifica fail** +`python3 -m pytest tests/test_traxsource.py -v` → FAIL sui nuovi. + +- [ ] **Step 3: Implementa helper + eccezioni in `core/traxsource.py`** + +Aggiungi in fondo al modulo: + +```python +import re + + +class TraxsourceError(Exception): + """Base per errori Traxsource.""" + + +class TraxsourceUnreachableError(TraxsourceError): + """Rete / 5xx dopo retry.""" + + +class TraxsourceParseError(TraxsourceError): + """HTML ricevuto ma non conforme allo schema atteso.""" + + +_MIX_PAREN_RE = re.compile(r"^(.*)\s*\(([^()]+)\)\s*$") +_SIZE_RE = re.compile(r"/\d+x\d+/") + + +def _split_title_mix(full_title: str) -> tuple: + """Estrae mix dalle parentesi finali. 'Foo (Extended Mix)' -> ('Foo', 'Extended Mix'). + Se non ci sono parentesi finali, mix = ''.""" + if not full_title: + return ("", "") + m = _MIX_PAREN_RE.match(full_title.strip()) + if m: + return (m.group(1).strip(), m.group(2).strip()) + return (full_title.strip(), "") + + +def _format_artists(names: list) -> str: + """['A', 'B', 'C'] -> 'A, B & C'. Strips whitespace.""" + clean = [n.strip() for n in names if n and n.strip()] + if not clean: + return "" + if len(clean) == 1: + return clean[0] + return ", ".join(clean[:-1]) + " & " + clean[-1] + + +def _large_cover(url: str) -> str: + """Sostituisce /NxN/ nel path con /500x500/. Se pattern assente, ritorna invariato.""" + if not url: + return "" + return _SIZE_RE.sub("/500x500/", url) +``` + +- [ ] **Step 4: Verifica PASS** +`python3 -m pytest tests/test_traxsource.py -v` +Expected: ~17 test PASS. + +- [ ] **Step 5: Commit** +```bash +git add core/traxsource.py tests/test_traxsource.py +git -c user.email=info@djluza.com commit -m "traxsource: helper _split_title_mix + _format_artists + _large_cover + eccezioni" +``` + +--- + +## Task 4: _discover_top100_url + _parse_tracks — TDD + +**Files:** +- Modify: `core/traxsource.py` +- Modify: `tests/test_traxsource.py` + +- [ ] **Step 1: Test** + +Aggiungi: +```python +class TestDiscoverTop100Url: + def test_extracts_link_from_genre_page(self, fixtures_dir): + html = (fixtures_dir / "traxsource_tech_house_genre.html").read_text() + url = traxsource._discover_top100_url(html) + assert url.startswith("/title/") + assert "top-100" in url + + def test_raises_when_no_link(self): + with pytest.raises(traxsource.TraxsourceParseError, match="Top 100"): + traxsource._discover_top100_url("nulla") + + +class TestParseTracks: + def test_parses_100_tracks(self, fixtures_dir): + html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text() + tracks = traxsource._parse_tracks(html) + assert len(tracks) == 100 + + def test_positions_sequential_1_to_100(self, fixtures_dir): + html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text() + tracks = traxsource._parse_tracks(html) + positions = [t.position for t in tracks] + assert positions == list(range(1, 101)) + + def test_track_shape(self, fixtures_dir): + html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text() + tracks = traxsource._parse_tracks(html) + first = tracks[0] + assert first.title + assert first.artists + assert first.traxsource_id > 0 + assert first.slug + assert first.image_url.startswith("https://") + assert first.cover_url_large.startswith("https://") + assert "500x500" in first.cover_url_large + + def test_raises_when_no_tracks(self): + with pytest.raises(traxsource.TraxsourceParseError, match="track"): + traxsource._parse_tracks("vuoto") +``` + +- [ ] **Step 2: Verifica fail** + +- [ ] **Step 3: Implementa** + +Aggiungi in `core/traxsource.py`: + +```python +from bs4 import BeautifulSoup + + +_TOP100_LINK_RE = re.compile(r'href="(/title/\d+/top-100-[a-z0-9-]+)"') + + +def _discover_top100_url(genre_html: str) -> str: + """Estrae il path relativo della playlist Top 100 corrente dalla pagina di un genere.""" + m = _TOP100_LINK_RE.search(genre_html) + if not m: + raise TraxsourceParseError("link Top 100 non trovato nella pagina genere") + return m.group(1) + + +def _parse_tracks(html: str) -> list: + """Parsa la pagina Top 100 (title playlist) e ritorna list[TraxsourceTrack].""" + soup = BeautifulSoup(html, "html.parser") + rows = soup.select("div.top-item.play-trk") + if not rows: + raise TraxsourceParseError("nessuna div.top-item.play-trk trovata") + + out: list = [] + for i, row in enumerate(rows, 1): + try: + trid = int(row.get("data-trid") or 0) + pos_el = row.select_one("div.ttib.position") + position = i # fallback su enumerate se pos manca + if pos_el and pos_el.text.strip().isdigit(): + position = int(pos_el.text.strip()) + + title_a = row.select_one("a.com-title") + if not title_a: + continue + full_title = title_a.text.strip() + title, mix = _split_title_mix(full_title) + slug = (title_a.get("href") or "").split("/")[-1] + + artist_names = [a.text for a in row.select("a.com-artists")] + artists = _format_artists(artist_names) + + label_a = row.select_one("a.com-label") + label = label_a.text.strip() if label_a else "" + + img = row.select_one("img") + image_url = (img.get("src") or "") if img else "" + cover_large = _large_cover(image_url) + + out.append(TraxsourceTrack( + position=position, + title=title, + mix=mix, + artists=artists, + label=label, + traxsource_id=trid, + slug=slug, + image_url=image_url, + cover_url_large=cover_large, + )) + except Exception as e: + raise TraxsourceParseError(f"errore parse track[{i}]: {e}") from e + return out +``` + +- [ ] **Step 4: Verifica PASS** +Expected: ~23 test verdi totali. + +Se `test_parses_100_tracks` fallisce: il selettore CSS è diverso. Ispeziona la fixture (`grep -c 'top-item play-trk' fixture.html`) e adatta il selettore in `_parse_tracks`. + +- [ ] **Step 5: Commit** +```bash +git add core/traxsource.py tests/test_traxsource.py +git -c user.email=info@djluza.com commit -m "traxsource: _discover_top100_url + _parse_tracks con BeautifulSoup" +``` + +--- + +## Task 5: fetch_top100 con session + retry + cache — TDD + +**Files:** +- Modify: `core/traxsource.py` +- Modify: `tests/test_traxsource.py` + +- [ ] **Step 1: Test (mock su _session per non chiamare la rete)** + +```python +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 genre_html(self, fixtures_dir): + return (fixtures_dir / "traxsource_tech_house_genre.html").read_text() + + @pytest.fixture + def top100_html(self, fixtures_dir): + return (fixtures_dir / "traxsource_tech_house_top100.html").read_text() + + def test_success_returns_100_tracks(self, genre_html, top100_html): + traxsource._cache.clear() + mock_sess = MagicMock() + mock_sess.get.side_effect = [ + _mock_response(genre_html, 200), + _mock_response(top100_html, 200), + ] + with patch("core.traxsource._session", return_value=mock_sess): + tracks = traxsource.fetch_top100("tech-house") + assert len(tracks) == 100 + + def test_invalid_slug_raises_value_error(self): + with pytest.raises(ValueError, match="slug"): + traxsource.fetch_top100("not-a-genre") + + def test_5xx_retries_and_raises_unreachable(self): + traxsource._cache.clear() + mock_sess = MagicMock() + mock_sess.get.return_value = _mock_response("", 503) + with patch("core.traxsource._session", return_value=mock_sess), \ + patch("core.traxsource.time.sleep"): + with pytest.raises(traxsource.TraxsourceUnreachableError): + traxsource.fetch_top100("tech-house") + # 3 tentativi + assert mock_sess.get.call_count == 3 + + def test_cache_hit_within_ttl(self, genre_html, top100_html): + traxsource._cache.clear() + mock_sess = MagicMock() + mock_sess.get.side_effect = [ + _mock_response(genre_html, 200), + _mock_response(top100_html, 200), + ] + with patch("core.traxsource._session", return_value=mock_sess): + traxsource.fetch_top100("tech-house") + traxsource.fetch_top100("tech-house") + # 2 chiamate al primo fetch (genre + top100), 0 al secondo + assert mock_sess.get.call_count == 2 + + def test_force_refresh_bypasses_cache(self, genre_html, top100_html): + traxsource._cache.clear() + mock_sess = MagicMock() + # 4 risposte (2 fetch × 2 richieste ciascuno) + mock_sess.get.side_effect = [ + _mock_response(genre_html, 200), + _mock_response(top100_html, 200), + _mock_response(genre_html, 200), + _mock_response(top100_html, 200), + ] + with patch("core.traxsource._session", return_value=mock_sess): + traxsource.fetch_top100("tech-house") + traxsource.fetch_top100("tech-house", force_refresh=True) + assert mock_sess.get.call_count == 4 +``` + +- [ ] **Step 2: Verifica fail** + +- [ ] **Step 3: Implementa in `core/traxsource.py`** + +Aggiungi in cima (dopo `import re`): +```python +import time +``` + +Aggiungi in fondo: + +```python +_IMPERSONATE = "chrome131" +_REQUEST_TIMEOUT = 15 +_MAX_ATTEMPTS = 3 +_BACKOFF_SEC = [1, 3] +_CACHE_TTL_SEC = 15 * 60 + +_cache: dict = {} +_session_singleton = None + + +def _session(): + """Ritorna la Session curl_cffi singleton, preriscaldata con GET a /.""" + global _session_singleton + if _session_singleton is None: + from curl_cffi import requests as _cffi + _session_singleton = _cffi.Session(impersonate=_IMPERSONATE) + try: + _session_singleton.get("https://www.traxsource.com/", timeout=_REQUEST_TIMEOUT) + except Exception: + pass # cookie CF possono arrivare comunque + return _session_singleton + + +def _do_get(session, url: str) -> str: + """GET con retry + backoff. Include Referer per pagine interne.""" + last_exc = None + for attempt in range(_MAX_ATTEMPTS): + try: + resp = session.get( + url, + timeout=_REQUEST_TIMEOUT, + headers={"Referer": "https://www.traxsource.com/"}, + ) + 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 TraxsourceUnreachableError( + f"Traxsource irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}" + ) + + +def fetch_top100(slug: str, force_refresh: bool = False) -> list: + """Fetches Top 100 corrente per il genere. + + 1. Verifica slug in GENRES + 2. Fetch pagina genre → _discover_top100_url + 3. Fetch pagina Top 100 → _parse_tracks + 4. Cache 15 min + + Raises: + ValueError: slug non in GENRES + TraxsourceUnreachableError: rete/5xx dopo retry + TraxsourceParseError: HTML non conforme + """ + 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] + + gid, _name = GENRES[slug] + sess = _session() + + genre_url = f"https://www.traxsource.com/genre/{gid}/{slug}" + genre_html = _do_get(sess, genre_url) + top100_path = _discover_top100_url(genre_html) + + top100_url = "https://www.traxsource.com" + top100_path + top100_html = _do_get(sess, top100_url) + tracks = _parse_tracks(top100_html) + + _cache[slug] = (now, tracks) + return tracks +``` + +- [ ] **Step 4: Verifica** +`python3 -m pytest tests/ -v 2>&1 | tail -3` +Expected: ~28 test verdi totali (5 preesistenti + nuovi). + +- [ ] **Step 5: Commit** +```bash +git add core/traxsource.py tests/test_traxsource.py +git -c user.email=info@djluza.com commit -m "traxsource: fetch_top100 con session curl_cffi + retry + cache 15min" +``` + +--- + +## Task 6: Config bump v1.9.0 + traxsource_last_genre + +**Files:** `core/config.py` + +- [ ] **Step 1:** Cambia `VERSION = "v1.8.6"` in `VERSION = "v1.9.0"`. +- [ ] **Step 2:** Nel dict `DEFAULTS`, aggiungi dopo `beatport_last_genre`: +```python + "traxsource_last_genre": "tech-house", +``` +- [ ] **Step 3:** Verifica: +```bash +python3 -c "from core.config import load_config, VERSION; c = load_config(); print(VERSION, c.get('traxsource_last_genre'))" +``` +Expected: `v1.9.0 tech-house`. +- [ ] **Step 4:** Commit: +```bash +git add core/config.py +git -c user.email=info@djluza.com commit -m "config: v1.9.0 + traxsource_last_genre nei DEFAULTS" +``` + +--- + +## Task 7: bridge — 4 metodi Api Traxsource + +**Files:** `api/bridge.py` + +- [ ] **Step 1: Aggiungi `from core import traxsource` agli import** + +Trova la sezione `from core import beatport, spotify_client` e aggiungi `traxsource`: +```python +from core import beatport, spotify_client, traxsource +``` + +- [ ] **Step 2: Aggiungi 4 metodi + helper in fondo alla classe Api (dopo i metodi beatport)** + +Il pattern è identico a beatport_*. Adatta solo i nomi + il calcolo del subfolder (`Traxsource_` invece di `Beatport_`) + i campi metadata (album=label per Traxsource, non nome release). + +```python + # ================================================================ + # Traxsource charts + # ================================================================ + + def _traxsource_output_dir(self, out_root: str, genre_name: str) -> Path: + safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip() + subfolder = f"Traxsource_{safe_genre}" if safe_genre else "Traxsource" + return Path(out_root) / subfolder + + def traxsource_genres(self) -> list: + return traxsource.list_genres() + + def traxsource_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict: + try: + cfg = load_config() + cfg["traxsource_last_genre"] = slug + save_config(cfg) + except Exception: + pass + try: + tracks = traxsource.fetch_top100(slug, force_refresh=force_refresh) + except ValueError as e: + return {"ok": False, "error": "invalid_genre", "message": str(e)} + except traxsource.TraxsourceUnreachableError as e: + return {"ok": False, "error": "unreachable", "message": str(e)} + except traxsource.TraxsourceParseError as e: + return {"ok": False, "error": "parse", "message": str(e)} + return {"ok": True, "tracks": [asdict(t) for t in tracks]} + + def traxsource_check_existing(self, tracks: list, genre_name: str) -> list: + cfg = load_config() + out_root = (cfg.get("output_dir") or "").strip() + if not out_root: + return [False] * len(tracks) + out_dir = self._traxsource_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 + result.append(any( + (title in stem and first_artist in stem) + for stem in existing_stems + )) + return result + + def traxsource_download_selected(self, tracks: list, genre_name: str) -> dict: + cfg = load_config() + out_root = (cfg.get("output_dir") or "").strip() + if not out_root: + return {"ok": False, "error": "Cartella output non impostata"} + target = self._traxsource_output_dir(out_root, genre_name) + subfolder = target.name + + # Costruisci tracks per start_tracks_download + metadata paralleli per il tagger + import datetime as _dt + today = _dt.date.today() + current_month = today.strftime("%Y-%m") + + converted = [] + metadata = [] + 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}) + metadata.append({ + "title": title, + "artist": artists, + "album": (t.get("label") or "").strip() or f"Traxsource Top 100 {current_month}", + "date": current_month, + "genre": genre_name, + "cover_url": t.get("cover_url_large") or t.get("image_url") or "", + }) + + if not converted: + return {"ok": False, "error": "Nessun brano valido"} + + return self.start_tracks_download({ + "tracks": converted, + "output_dir": out_root, + "subfolder": subfolder, + "metadata": metadata, + }) +``` + +- [ ] **Step 3: Smoke test bridge** +```bash +python3 -c "from api.bridge import Api; a=Api(); print('genres:', len(a.traxsource_genres()))" +python3 -c "from api.bridge import Api; a=Api(); print('empty check:', a.traxsource_check_existing([], 'X'))" +``` +Expected: genres ≥10, empty check `[]`. + +- [ ] **Step 4: Full pytest** +`python3 -m pytest tests/ -v 2>&1 | tail -3` → no regressioni. + +- [ ] **Step 5: Commit** +```bash +git add api/bridge.py +git -c user.email=info@djluza.com commit -m "bridge: 4 metodi Api Traxsource (parallelo Beatport, metadata album=label)" +``` + +--- + +## Task 8: UI (HTML + JS + CSS) + +**Files:** +- Modify: `webui/index.html` +- Modify: `webui/js/app.js` + +Struttura identica a Beatport. Sostituisci `beatport-*` con `traxsource-*` negli ID e replica il modulo JS. + +- [ ] **Step 1: HTML — nuova nav-item + section** + +Dopo il nav-item Beatport (`data-view="beatport"`), aggiungi: +```html + +``` + +Dopo `
…
`, aggiungi (copia adattata): + +```html +
+
+
+
TRAXSOURCE
+

Top 100 Traxsource per genere

+

La classifica mensile ufficiale · Scarica in un click con tag ID3 completi

+
+
▲
+
+ +
+
+ + + Shift+click per forzare refresh (bypass cache 15min) +
+
+
+ + + + +
+ +
+ +
+
+
+``` + +- [ ] **Step 2: JS — modulo TraxsourceUI** + +Copia intera l'IIFE `BeatportUI` da `webui/js/app.js` (dalla riga `const BeatportUI = (function () {` fino al `})();` di chiusura) e replica come `TraxsourceUI` **dopo YoutubeUI**. Poi sostituisci sistematicamente: + +- `beatport` → `traxsource` (tutti gli ID, chiavi API, nomi config) +- `Beatport` → `Traxsource` (nomi visibili nei log/status) +- Nella renderTable: le colonne sono diverse — sostituisci il rendering di "Titolo (Mix)" per Traxsource (già così), aggiungi colonna Label: + ```javascript + const tdLabel = document.createElement("td"); + tdLabel.textContent = t.label || ""; + tr.appendChild(tdLabel); + ``` + E rimuovi la colonna Durata (Traxsource non ha duration nel markup). +- Nel `updateOutputInfo`, cartella `Traxsource_` invece di `Beatport/`. + +**Aggiungi `traxsource` a `logEls`:** +```javascript + traxsource: () => document.getElementById("traxsource-log"), +``` + +**Aggiungi bootstrap:** +Trova `await BeatportUI.init();` in `init()` principale (~riga 508), aggiungi dopo: +```javascript + await TraxsourceUI.init(); +``` + +- [ ] **Step 3: Verifiche** +```bash +node --check webui/js/app.js +python3 -m pytest tests/ 2>&1 | tail -3 +python3 -c "from api.bridge import Api; a=Api(); print(len(a.traxsource_genres()))" +``` +Expected: JS OK, tutti test verdi, ≥10 generi. + +- [ ] **Step 4: Commit (2 separati)** +```bash +git add webui/index.html +git -c user.email=info@djluza.com commit -m "ui: markup tab Traxsource" +git add webui/js/app.js +git -c user.email=info@djluza.com commit -m "ui: TraxsourceUI (init, loadChart, render, download)" +``` + +--- + +## Task 9: Test finale + release v1.9.0 + +- [ ] **Step 1:** `python3 -m pytest tests/ -v` → tutti verdi (61 + ~13 Traxsource = ~74) +- [ ] **Step 2:** Test manuale end-to-end (vedi spec sez. "Test manuale end-to-end") +- [ ] **Step 3:** `/tmp/notes-v1.9.0.md` con descrizione feature +- [ ] **Step 4:** Merge branch, tag `v1.9.0`, push. STOP prima del push per conferma utente (protocollo release del repo — vedi memory `musictools-release-flow`). +- [ ] **Step 5:** CI watch + apply notes + upload builds server + insert DB (flusso standard). + +## Se qualcosa va storto + +- **Cloudflare block persistente su session:** aumenta la pausa dopo GET `/`, o aggiungi header `Sec-Fetch-*` a mano. Se ancora bloccato: rotare `impersonate` (`chrome`, `chrome124`, `safari17_2`) e vedere quale passa +- **CSS class name cambiati:** BeautifulSoup fallisce silenzioso su selettori inesistenti → `_parse_tracks` solleva `TraxsourceParseError("nessuna div.top-item.play-trk trovata")`. Ispeziona `tests/fixtures/traxsource_tech_house_top100.html` per il vero markup e aggiorna il selettore +- **Meno di 100 track parsati:** verifica che la pagina Top 100 non abbia paginazione (Traxsource storicamente carica tutto server-side, ma se cambia serve una seconda GET) diff --git a/docs/superpowers/specs/2026-08-01-traxsource-charts-design.md b/docs/superpowers/specs/2026-08-01-traxsource-charts-design.md new file mode 100644 index 0000000..aed1fe3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-traxsource-charts-design.md @@ -0,0 +1,256 @@ +# Traxsource Charts — Design Spec + +- **Data:** 2026-08-01 +- **Autore:** LuZa + Claude +- **Stato:** Approvato, pronto per implementation plan +- **Target release:** MusicTools v1.9.0 + +## Obiettivo + +Nuova tab "▲ Traxsource" nell'app che permette di: + +1. Scegliere un genere musicale da un elenco di ~15 generi Traxsource +2. Caricare la **Top 100** ufficiale del mese corrente per quel genere +3. Anteprima checkbox + download riusando il pipeline esistente (Spotify search → yt-dlp → tag ID3 + cover) + +Sostituisce l'attività manuale di copiare/incollare tracklist Traxsource. + +## Non-goals + +- Chart diverse dalla Top 100 (Must Have, Just Added, ecc.) +- Ricerca artista/label specifica su Traxsource +- Preview audio (Traxsource ha DRM) +- Duration tracks (non è nell'HTML della lista; sarebbe una richiesta extra per track) +- Login/account Traxsource + +## Ricognizione tecnica (fatta 2026-08-01) + +- **Cloudflare Managed Challenge attivo** su tutte le pagine tranne home e /top. Bypass: session-based con `curl_cffi.Session(impersonate='chrome131')`, prima GET a `/`, poi le pagine sensibili con `Referer: https://www.traxsource.com/`. +- **Non è Next.js** → no `__NEXT_DATA__`. HTML server-rendered. +- **Struttura URL:** + - Genre index: `/genre//` — es. `/genre/18/tech-house`. Contiene solo 10 top track più altri widget + - Top 100 corrente: `/title//top-100--of--` — es. `/title/2847986/top-100-tech-house-of-july-2026`. Contiene 100 track completi in un unico documento (~394KB) + - **Il title_id cambia ogni mese.** Non è hardcodabile. Va scoperto dinamicamente +- **Discovery Top 100 URL:** nella pagina `/genre//` c'è (almeno un) `
`. Estrai col regex `href="(/title/\d+/top-100-[a-z0-9-]+)"` +- **Track markup (stabile):** + ```html + + ``` + Class names sono stabili (semantici, non generati da bundler). +- **Cover art:** URL contiene la size (`/52x52/`). Sostituendo con `/500x500/` si ottiene la versione grande per tagging ID3. + +## Approccio scelto + +- Session `curl_cffi` con `impersonate='chrome131'`. Cookie CF conservati automaticamente. Preriscaldamento con GET a `/` al primo utilizzo. +- Parser: `BeautifulSoup4` con selettori CSS puliti — `div.top-item.play-trk` per righe, `a.com-title` per titolo, `a.com-artists` per artisti, `a.com-label` per label, `div.ttib.position` per rank, `img` per cover URL. +- Regex sul titolo per estrarre "mix name" tra parentesi: `Song Title (Extended Mix)` → title="Song Title", mix="Extended Mix". +- Cache in-memory 15 min per la lista, come Beatport. +- Genere → (numeric_id, slug, display_name) map hardcoded (estratti dalla home /top). + +**Alternative scartate:** +- `requests` puro: bloccato da CF, verificato +- API interna Traxsource: non esistono API pubbliche gratuite documentate; reverse engineering rischio ban +- Playwright: overkill (~100MB dep) quando session + BeautifulSoup basta + +## Architettura + +### Nuovo modulo `core/traxsource.py` + +```python +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +# Mappa slug → (numeric_id, display_name) +# Verificati 2026-08-01 dalla home /top di Traxsource +GENRES: dict = { + "afro-house": (33, "Afro House"), + "afro-latin-brazilian": (23, "Afro / Latin / Brazilian"), + "amapiano": (37, "Amapiano"), + "bass-club": (25, "Bass / Club"), + "breaks-uk-bass": (14, "Breaks / UK Bass"), + "classic-house": (12, "Classic House"), + "deep-house": (13, "Deep House"), + "dj-tools": (16, "DJ Tools"), + "downtempo-nu-disco-indie-dance": (22, "Downtempo / Nu Disco / Indie Dance"), + "electronica": (5, "Electronica"), + "electro-house": (11, "Electro House"), + "funky-jackin-groovy": (15, "Funky / Jackin / Groovy"), + "garage": (29, "Garage"), + "house": (1, "House"), + "lounge-chill-out": (10, "Lounge / Chill Out"), + "melodic-house-techno-progressive-house": (34, "Melodic House / Techno / Progressive House"), + "minimal-deep-tech": (27, "Minimal / Deep Tech"), + "organic-house-downtempo": (36, "Organic House / Downtempo"), + "progressive-house": (28, "Progressive House"), + "r-and-b-hip-hop": (6, "R&B / Hip Hop"), + "soulful-house": (24, "Soulful House"), + "tech-house": (18, "Tech House"), + "techno-peak-time-driving": (26, "Techno (Peak Time / Driving)"), + "techno-raw-deep-hypnotic": (32, "Techno (Raw / Deep / Hypnotic)"), + "trance": (30, "Trance"), + "world-reggae": (21, "World / Reggae"), +} +# NOTA: la mappa è enumerata in fase di implementazione fetchando /top con curl_cffi +# e cercando href="/genre//". Vedi Task 1 del plan. + + +@dataclass(frozen=True) +class TraxsourceTrack: + position: int + title: str + mix: str + artists: str # "A, B & C" formattato + label: str + traxsource_id: int + slug: str + image_url: str = "" # URL thumbnail (per UI) + cover_url_large: str = "" # URL cover 500x500 (per tagging) + + +class TraxsourceError(Exception): pass +class TraxsourceUnreachableError(TraxsourceError): pass +class TraxsourceParseError(TraxsourceError): pass + + +def list_genres() -> list: + """Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name.""" + + +def fetch_top100(slug: str, force_refresh: bool = False) -> list[TraxsourceTrack]: + """Fetches Top 100 corrente per il genere. + + 1. Scopre l'URL della playlist Top 100 del mese via GET /genre// + 2. Fetch della playlist /title//top-100-... + 3. Parse HTML con BeautifulSoup + 4. Cache in-memory 15 min + + Raises: + ValueError se slug non in GENRES + TraxsourceUnreachableError su rete/5xx dopo retry + TraxsourceParseError su HTML non conforme (title link non trovato, meno di 50 track, ecc.) + """ +``` + +**Internals:** +- `_session()` singleton — inizializza `curl_cffi.Session(impersonate='chrome131')` e fa GET a `/` una volta per riscaldare CF cookies. Riusata per tutte le fetch successive. +- `_discover_top100_url(session, slug) → str` — fetcha `/genre//`, regex per il link Top 100 +- `_parse_tracks(html) → list[TraxsourceTrack]` — BeautifulSoup, itera `div.top-item.play-trk`, estrae campi +- `_split_title_mix(full_title) → (title, mix)` — parsa `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")`. Se no parentesi, mix = "". +- `_format_artists(anchors) → str` — join `[a.text for a in anchors]` con formattazione "A, B & C" +- `_large_cover(url) → str` — sostituisce `/52x52/` con `/500x500/` nel path + +**Retry + timeout:** riusa gli stessi pattern di `core/beatport.py`: 3 tentativi con backoff [1, 3]s, timeout 15s. + +### Nuovi metodi in `api/bridge.py` + +Paralleli ai metodi Beatport (stesso schema): + +- `traxsource_genres() → list[dict]` +- `traxsource_fetch_chart(slug, force_refresh) → {ok, tracks} | {ok:false, error, message}` — salva anche `traxsource_last_genre` in config +- `traxsource_check_existing(tracks, genre_name) → list[bool]` — check in `/Traxsource_/` +- `traxsource_download_selected(tracks, genre_name) → dict` — costruisce metadata (title/artist/album=label/date=YYYY-MM/genre=display_name/cover_url) e chiama `start_tracks_download` riusando il pipeline con tagging ID3 + +**Metadata per il tagger:** +- title, artist: dai campi track +- album: label Traxsource (best proxy — non c'è "album" per singole tracce) +- date: mese/anno corrente (dato che la Top 100 è mensile) +- genre: display_name del genere +- cover_url: `cover_url_large` (500x500) + +### Frontend + +Nuova tab in sidebar dopo YouTube (o Beatport): +```html + +``` + +Section `#view-traxsource` con struttura identica a Beatport (dropdown genere + Carica Top 100 + tabella + toolbar + log). Riusa tutte le classi `.beatport-*` per lo stile. + +Modulo `TraxsourceUI` in `webui/js/app.js` — copia diretta di `BeatportUI` con selettori cambiati (`traxsource-*` invece di `beatport-*`) e chiamate API alle nuove funzioni. + +**Ordinamento colonne** (sortable) e **cover art** già supportati riusando la stessa struttura tabella + modulo helper `_sortPairs`/`_bindSortableHeaders`/`_updateSortArrows` esistenti. + +### Persistenza + +Nuovo campo in `core/config.py::DEFAULTS`: +```python +"traxsource_last_genre": "tech-house", +``` + +### Dipendenze + +- `beautifulsoup4` — nuova dep runtime. Aggiungere in `requirements.txt`. Serve `--collect-data bs4` in build_windows.py + build_macos.py? Verificare — bs4 di solito no. + +## Testing + +**Unit tests `tests/test_traxsource.py` (senza rete, con fixture HTML):** + +Serve una fixture HTML reale della pagina Top 100 (~400KB). Task 1 la genera con `curl_cffi` come fatto per Beatport (`beatport_melodic_top100.html`). + +| Test | Verifica | +|---|---| +| `test_list_genres_shape` | Lista dict con {slug, id, name}, ordinati | +| `test_parse_top100_extracts_100_tracks` | Fixture HTML → 100 track | +| `test_parse_positions_sequential` | 1..100 senza buchi | +| `test_parse_track_shape` | Ogni track ha title, artists, label, cover URLs non vuoti | +| `test_split_title_mix_with_parens` | `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")` | +| `test_split_title_mix_no_parens` | `"Foo"` → `("Foo", "")` | +| `test_format_artists_multi` | `["A", "B", "C"]` → `"A, B & C"` | +| `test_large_cover_url_substitution` | `.../52x52/x.jpg` → `.../500x500/x.jpg` | +| `test_discover_top100_url_returns_playlist_link` | HTML genre stub → estrae `/title/N/top-100-...` | +| `test_fetch_top100_invalid_slug_raises_value_error` | slug non in GENRES → ValueError early | + +Mock HTTP con `unittest.mock.patch("core.traxsource._session")` (patch della session singleton). + +**Test manuale end-to-end:** +1. Avvia app → tab Traxsource +2. Selezione "Tech House" → Carica Top 100 → tabella 100 righe in <5s +3. Verifica cover art (thumbnail nella tabella) +4. Deseleziona 90, tieni 10 → Scarica selezionati +5. File in `MUSICA/Traxsource_Tech House/` con tag ID3 completi (title, artist, album=label, genre=Tech House, cover 500x500) +6. Ricarica stesso genere → cache hit +7. Riavvio → ultimo genere ricordato + +## Rollout + +1. Branch `feat/traxsource-charts` +2. Bump `core/config.py::VERSION` → `v1.9.0` (minor: nuova tab visibile) +3. Note release `/tmp/notes-v1.9.0.md` +4. Merge → tag → CI → server DB update (stesso flusso di sempre) + +## Struttura file impattati + +**Nuovi:** +- `core/traxsource.py` (~250 LOC) +- `tests/test_traxsource.py` (~200 LOC) +- `tests/fixtures/traxsource_tech_house_top100.html` (~400KB) +- `scripts/refresh_traxsource_genres.py` (~50 LOC, opzionale) + +**Modificati:** +- `requirements.txt` — `beautifulsoup4>=4.12.0` +- `build_windows.py` + `build_macos.py` — verificare se serve `--collect-data bs4` +- `core/config.py` — VERSION bump + `traxsource_last_genre` +- `api/bridge.py` — 4 metodi Api `traxsource_*` (~150 LOC) +- `webui/index.html` — nav-item + section (~90 LOC) +- `webui/js/app.js` — modulo `TraxsourceUI` (~250 LOC, copia BeatportUI adattata) + +## Considerazioni operative + +- **Fragility scraping:** i class name di Traxsource (`com-title`, `com-artists`, `top-item`, ecc.) sono usati anche nel JS del sito → stabili nel tempo. Rischio: se Traxsource migra a un framework nuovo (React/Next), parser va rifatto. +- **Rate limiting:** un solo GET per la genre + un GET per la playlist Top 100 = 2 richieste per caricare la chart. Cache 15 min. Zero rischio ban in uso normale. +- **Legal:** stesso perimetro di Beatport — lettura di pagine pubbliche, no ToS violation esplicito. +- **Comparabilità con Beatport:** un utente power potrebbe usare entrambe le tab per triangolare le release del mese. UX consistente = curva apprendimento zero. diff --git a/requirements.txt b/requirements.txt index b6c1ff1..cfc129d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ 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 +beautifulsoup4>=4.12.0 # --- dev only --- pytest>=8.0.0 diff --git a/scripts/refresh_traxsource_genres.py b/scripts/refresh_traxsource_genres.py new file mode 100644 index 0000000..5c7a540 --- /dev/null +++ b/scripts/refresh_traxsource_genres.py @@ -0,0 +1,34 @@ +"""Estrae la lista dei generi Traxsource dalla pagina /top. +Uso: python3 scripts/refresh_traxsource_genres.py > /tmp/tx_genres.txt +Poi copia manualmente in core/traxsource.py::GENRES.""" + +from __future__ import annotations + +import re +import sys +from curl_cffi import requests + + +def main() -> int: + s = requests.Session(impersonate="chrome131") + s.get("https://www.traxsource.com/", timeout=15) + r = s.get("https://www.traxsource.com/top", timeout=15, + headers={"Referer": "https://www.traxsource.com/"}) + if r.status_code != 200: + print(f"HTTP {r.status_code}", file=sys.stderr) + return 1 + # href="/genre//" + hits = re.findall(r'href="/genre/(\d+)/([a-z0-9-]+)"', r.text) + seen = set() + for gid, slug in hits: + if slug in seen: + continue + seen.add(slug) + # Display name = slug con capitalizzazione euristica (l'utente lo raffina a mano) + name = " ".join(w.capitalize() for w in slug.replace("-", " ").split()) + print(f' "{slug}": ({gid}, "{name}"),') + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/traxsource_tech_house_genre.html b/tests/fixtures/traxsource_tech_house_genre.html new file mode 100644 index 0000000..7f33a19 --- /dev/null +++ b/tests/fixtures/traxsource_tech_house_genre.html @@ -0,0 +1,2882 @@ + + + + Tech House: Get Tech House Tracks on Traxsource + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+ +
+ + + + + + +
+
  +
+  
+  
+
+   +   +   +   +   +
+
+
Album Only
+
+ + +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ + + +
+ +
+
+ +
+ + + +
+ +
+

Tech House

+
+ + +
+
+ +
+ + DJ TOP 10s +
+ + +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ + +
+
+
+ +
+ +
+ +
+ + +
+
+
+ +
+ +
+ +
+ + +
+
+
+ +
+ +
+ +
+ + +
+
+
+ +
+ +
+ +
+ + +
+
+
+ +
+ +
+ +
+ + +
+
+
+ + +
+ + SEE ALL ... + + + +
+ + +
+ + + + + +
+ TECH HOUSE +
+ + +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ + SEE ALL ... + + +
+
+ +
+ + +
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+

+

Make Default

+ Save +
+
+ Yes + No +
+
+ + +
+ +
Embed a player for
+ +
+ Copy the above code and paste it into your website or blog. +
+
+ +
+ +
+ +
+
+ +
+ + + + +
+
+ +
+ + +
+
+
+
+ +
+ + +
+
audio origin
+
+ + + +
+ + + +
analyzed by
+
+ + +
+
+ + + + + diff --git a/tests/fixtures/traxsource_tech_house_top100.html b/tests/fixtures/traxsource_tech_house_top100.html new file mode 100644 index 0000000..93f3f58 --- /dev/null +++ b/tests/fixtures/traxsource_tech_house_top100.html @@ -0,0 +1,6332 @@ + + + + Various Artists - Top 100 Tech House of July 2026 on Traxsource + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+ +
+ + + + + + +
+
  +
+  
+  
+
+   +   +   +   +   +
+
+
Album Only
+
+ + +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ + + +
+ +
+
+ +
+ + + +
+ +
+ +
+
+
+ +
+

Top 100 Tech House of July 2026

+

Various Artists

+ Essential Tech +
+
TT-201597 | 2026-07-30
+ +
+
+ +
+
+ +
+
+
1
+
+ +
+ + + +
+
+ Deep Inside Groove
Original Mix (7:11) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
2
+
+ +
+ + + +
+
+ Badman Sound
Extended Mix (5:11) +
+ Nervous +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
3
+
+ +
+ + + +
+
+ That Funky Rhythm
Extended Mix (5:30) +
+ DJ PP
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
4
+
+ +
+ + + +
+
+ Fired Up!
Malóne Morez Extended Remix (5:14) +
+ Nervous +
+ +
+
+ +
+   + follow: +
+ + + + + +
+ + +
+ +
+
+ +
+
+
5
+
+ +
+ + + +
+
+ Gator Boots
Extended Mix (4:19) +
+ Defected +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
6
+
+ +
+ + + +
+
+ I Wanna Dance
Extended Mix (6:05) +
+ DFTD +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ +
+
+ +
+
+
7
+
+ +
+ + + +
+
+ Gotta Have It
Extended Mix (4:40) +
+ Wh0 Plays +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ +
+
+ +
+
+
8
+
+ +
+ + + +
+
+ Silk
Mike The Connector & Daaeeru Extended Remix (6:41) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+ + +
+ + + +
+
+ +
+
+
9
+
+ +
+ + + +
+
+ Insane
Original Mix (5:46) +
+ D'EAUPE +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
10
+
+ +
+ + + +
+
+ All The Single Ladies
Extended Mix (4:34) +
+ Defected +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ +
+
+ +
+
+
11
+
+ +
+ + + +
+
+ For A Long Time
Original Mix (5:53) +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ +
+
+ +
+
+
12
+
+ +
+ + + +
+
+ La Nuit
Extended Mix (5:41) +
+ Supernova
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
13
+
+ +
+ + + +
+
+ Plastic Dreams
Extended Mix (5:38) +
+ Wh0, Rue Jay
+
+ Wh0 Plays +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
14
+
+ +
+ + + +
+
+ No Pares Sigue
Extended Mix (4:41) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ +
+
+
15
+
+ +
+ + + +
+
+ That's Right
Supernova Remix (5:38) +
+ La Zic +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
16
+
+ +
+ + + +
+
+ Bad Man
Extended Mix (5:09) +
+ Toolroom +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
17
+
+ +
+ + + +
+
+ Hit Me Now
Original Mix (6:11) +
+ Crusy
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
18
+
+ +
+ + + +
+
+ Pongo
Extended Mix (4:27) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ +
+
+
19
+
+ +
+ + + +
+
+ I'm Out
Extended Mix (7:01) +
+ OFFAIAH
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
20
+
+ +
+ + + +
+
+ 7 Heures Du Matin
Extended Mix (3:54) +
+ +
+
+ +
+   + follow: +
+ + + + + +
+
+ +
+
+
21
+
+ +
+ + + +
+
+ Feeling
(4:42) +
+ Huggz
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
22
+
+ +
+ + + +
+
+ Kürüf
Extended Mix (6:00) +
+ DJ PP
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
23
+
+ +
+ + + +
+
+ Dancefloor
Original Mix (5:45) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
24
+
+ +
+ + + +
+
+ Make Your Time
Original Mix (6:30) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
25
+
+ +
+ + + +
+
+ It's Time
Extended Mix (5:55) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + + + +
+
+ +
+
+
26
+
+ +
+ + + +
+
+ On The Line
Extended (5:07) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
27
+
+ +
+ + + +
+
+ Bring Up The Volume
Original Mix (7:03) +
+ +
+
+ +
+   + follow: +
+ + + + + + + +
+
+ +
+
+
28
+
+ +
+ + + +
+
+ Sunshine (Lose Control)
Club Mix (3:38) +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ + + +
+
+ +
+
+
29
+
+ +
+ + + +
+
+ Bounce UR Body
Extended Mix (4:13) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ +
+
+
30
+
+ +
+ + + +
+
+ Come A Lil Closer
(5:20) +
+ Nervous +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
31
+
+ +
+ + + +
+
+ Not That Clever
Extended Mix (5:41) +
+ Jano (ES)
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
32
+
+ +
+ + + +
+
+ F To This Beat
(6:01) +
+ Balanka
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
33
+
+ +
+ + + +
+
+ Reverie
Extended Mix (6:04) +
+ +
+
+ +
+   + follow: +
+ + + + + +
+
+ +
+
+
34
+
+ +
+ + + +
+
+ Just a Groove Thang
Original Mix (5:32) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
35
+
+ +
+ + + +
+
+ Feel My Body
Original Mix (4:42) +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ + + +
+
+ +
+
+
36
+
+ +
+ + + +
+
+ Kente Flow
Extended Mix (5:01) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+
+ +
+
+
37
+
+ +
+ + + +
+
+ TooDance
(5:54) +
+ Nervous +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
38
+
+ +
+ + + +
+
+ Walking On Sunshine
Norty Cotto Remix (5:22) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ +
+
+
39
+
+ +
+ + + +
+
+ Can You Feel It
Perpetual Motion Clockwork Orange Remix (5:56) +
+ Inhouse +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+
+ +
+
+
40
+
+ +
+ + + +
+
+ Sex Machine
Original Mix (6:57) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
41
+
+ +
+ + + +
+
+ All About
Original Mix (5:48) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
42
+
+ +
+ + + +
+
+ How Good
Extended Mix (6:11) +
+ DFTD +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
43
+
+ +
+ + + +
+
+ Blow Ya Mind
Extended Mix (4:22) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+ + +
+ +
+
+ +
+
+
44
+
+ +
+ + + +
+
+ Jewel Kid
+
+ Alleanza +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
45
+
+ +
+ + + +
+
+ Take Our Time
Extended Mix (6:32) +
+ +
+
+ +
+   + follow: +
+ + + + + +
+
+ +
+
+
46
+
+ +
+ + + +
+
+ Club Muzik
Extended Mix (5:36) +
+ 555 +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
47
+
+ +
+ + + +
+
+ Beat Of The Drum
Original Mix (5:17) +
+ Timbee
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
48
+
+ +
+ + + +
+
+ The Power
Extended Mix (5:01) +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ + + +
+
+ +
+
+
49
+
+ +
+ + + +
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
50
+
+ +
+ + + +
+
+ Mundo de Locos
CASSIMM Extended Mix (5:42) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+ + +
+ + + +
+
+ +
+
+
51
+
+ +
+ + + +
+
+ Rolling Strings
Extended Mix (4:50) +
+ +
+
+ +
+   + follow: +
+ + + + + +
+
+ +
+
+
52
+
+ +
+ + + +
+
+ Mi Combi
Original Mix (5:34) +
+ MVSON +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
53
+
+ +
+ + + +
+
+ Toledo
Original Mix (6:22) +
+ Maxdal
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
54
+
+ +
+ + + +
+
+ We're Going Back
Original Mix (5:47) +
+ Tenacious
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
55
+
+ +
+ + + +
+
+ Thrill Me
Extended Version (6:03) +
+ RH2 +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
56
+
+ +
+ + + +
+
+ Tech Talk
Extended Mix (5:42) +
+ Maxdal
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
57
+
+ +
+ + + +
+
+ Bounce & Bass
Extended Mix (5:45) +
+ Edible +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
58
+
+ +
+ + + +
+
+ Don Dadda
Extended Mix (6:16) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + + + +
+
+ +
+
+
59
+
+ +
+ + + +
+
+ Push It
Extended Mix (4:06) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+
+ +
+
+
60
+
+ +
+ + + +
+
+ Sonic Purple
Original Mix (5:00) +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ +
+
+ +
+
+
61
+
+ +
+ + + +
+
+ Alleanza +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
62
+
+ +
+ + + +
+
+ House Is Not A Trend
Original Mix (5:05) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
63
+
+ +
+ + + +
+
+ Walk & Walk
Original Mix (7:14) +
+ Invaders +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+ + +
+ +
+
+ +
+
+
64
+
+ +
+ + + +
+
+ NO TURNING BACK
Original Mix (4:34) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + + + +
+
+ +
+
+
65
+
+ +
+ + + +
+
+ Gozadera
Extended Mix (5:52) +
+ Maxdal
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
66
+
+ +
+ + + +
+
+ Respect
(3:31) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
67
+
+ +
+ + + +
+
+ Rockin
Extended Mix (5:06) +
+ Sayso
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
68
+
+ +
+ + + +
+
+ Original Gangsta
Original Mix (5:06) +
+ +
+
+ +
+   + follow: +
+ + + + + + + +
+
+ +
+
+
69
+
+ +
+ + + +
+
+ Can I Ride
Extended Mix (3:55) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
70
+
+ +
+ + + +
+
+ Look Into My Eyes
Extended Mix (5:35) +
+ Obskür
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
71
+
+ +
+ + + +
+
+ Work
Extended Mix (5:04) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
72
+
+ +
+ + + +
+
+ Freak Me Baby
(5:03) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
73
+
+ +
+ + + +
+
+ LOVE DESIRE
Extended Mix (5:48) +
+ CASSIMM
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
74
+
+ +
+ + + +
+
+ Verano en Azul
Extended Mix (4:49) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
75
+
+ +
+ + + +
+
+ Burn Down The House
Extended Mix (5:38) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ +
+
+
76
+
+ +
+ + + +
+
+ My Dream
Original Mix (5:41) +
+ +
+
+ +
+   + follow: +
+ + + + + +
+
+ +
+
+
77
+
+ +
+ + + +
+
+ Mi Vicio Tu
(4:54) +
+ PVRGVS +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ +
+
+ +
+
+
78
+
+ +
+ + + +
+
+ Baby
Tony Romera Remix (6:42) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
79
+
+ +
+ + + +
+
+ King King Acid
Original Mix (6:16) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
80
+
+ +
+ + + +
+
+ Loco
Olav Basoski Remix (5:53) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ +
+
+
81
+
+ +
+ + + +
+
+ This Sound
Original Mix (5:40) +
+ CASSIMM
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
82
+
+ +
+ + + +
+
+ You Feel That?
(6:31) +
+ Nervous +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
83
+
+ +
+ + + +
+
+ Hay Que Buena!
(5:45) +
+ +
+
+ +
+   + follow: +
+ + + +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
84
+
+ +
+ + + +
+
+ Rose
Extended Mix (4:55) +
+ Calego
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
85
+
+ +
+ + + +
+
+ LEFTI
+
+ Alleanza +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
86
+
+ +
+ + + +
+
+ The Light
Extended Mix (6:41) +
+ Supernova
+
+ Heattraxx +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
87
+
+ +
+ + + +
+
+ I Want Everything
Original Mix (5:19) +
+ Maxdal
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
88
+
+ +
+ + + +
+
+ What Is Luv
Extended Mix (5:26) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ +
+
+
89
+
+ +
+ + + +
+
+ Point
Original Mix (6:17) +
+ Nice7
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
90
+
+ +
+ + + +
+
+ Bipolar
Extended Mix (6:15) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
91
+
+ +
+ + + +
+
+ Call Me Crazy
Extended Mix (6:53) +
+ +
+
+ +
+   + follow: +
+ + + + + +
+
+ +
+
+
92
+
+ +
+ + + +
+
+ Freakitonga
Original Mix (4:34) +
+ +
+
+ +
+   + follow: +
+ + + + + +
+
+ +
+
+
93
+
+ +
+ + + +
+
+ Lost In Music
Original Mix (6:31) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
94
+
+ +
+ + + +
+
+ La La Land
(3:08) +
+ broke +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
95
+
+ +
+ + + +
+
+ Falastin
Original Mix (5:54) +
+ +
+
+ +
+   + follow: +
+ + + + + +
+
+ +
+
+
96
+
+ +
+ + + +
+
+ In My House Pumps
Original Mix (6:41) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
97
+
+ +
+ + + +
+
+ Feel The Rhythm
Extended Mix (6:00) +
+ DJ PP
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
98
+
+ +
+ + + +
+
+ Feel The Music
David Tort MoTL Extended Mix (6:35) +
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
99
+
+ +
+ + + +
+
+ Pôr Do Sol
Original Mix (5:31) +
+ Softpaw
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ + + +
+
+ +
+
+
100
+
+ +
+ + + +
+
+ Bang! Bang!
Original Mix (5:31) +
+ Dr SJ
+
+ +
+
+ +
+   + follow: +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+ + +
+
More from Essential Tech
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ +
+ +
Recommended For You
+
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+

+

Make Default

+ Save +
+
+ Yes + No +
+
+ + +
+ +
Embed a player for
+ +
+ Copy the above code and paste it into your website or blog. +
+
+ +
+ +
+ +
+
+ +
+ + + + +
+
+ +
+ + +
+
+
+
+ +
+ + +
+
audio origin
+
+ + + +
+ + + +
analyzed by
+
+ + +
+
+ + + + + diff --git a/tests/test_traxsource.py b/tests/test_traxsource.py new file mode 100644 index 0000000..f1fdd98 --- /dev/null +++ b/tests/test_traxsource.py @@ -0,0 +1,217 @@ +"""Test per core.traxsource.""" + +from __future__ import annotations + +import pytest + +from core import traxsource + + +class TestListGenres: + def test_returns_list_of_dicts(self): + result = traxsource.list_genres() + assert isinstance(result, list) + assert len(result) >= 10 + 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 = traxsource.list_genres() + names = [g["name"] for g in result] + assert names == sorted(names, key=str.casefold) + + def test_tech_house_present(self): + result = traxsource.list_genres() + slugs = [g["slug"] for g in result] + assert "tech-house" in slugs + + +class TestTraxsourceTrack: + def test_frozen(self): + t = traxsource.TraxsourceTrack( + position=1, title="X", mix="Y", artists="A", label="L", + traxsource_id=1, slug="x", + ) + with pytest.raises(Exception): + t.title = "Z" + + def test_defaults(self): + t = traxsource.TraxsourceTrack( + position=1, title="X", mix="", artists="A", label="", + traxsource_id=1, slug="x", + ) + assert t.image_url == "" + assert t.cover_url_large == "" + + +class TestSplitTitleMix: + def test_with_parens(self): + assert traxsource._split_title_mix("Foo (Extended Mix)") == ("Foo", "Extended Mix") + + def test_without_parens(self): + assert traxsource._split_title_mix("Foo") == ("Foo", "") + + def test_multiple_parens_takes_last(self): + # es. "Foo (feat. Bar) (Original Mix)" -> mix = "Original Mix" + assert traxsource._split_title_mix("Foo (feat. Bar) (Original Mix)") == ("Foo (feat. Bar)", "Original Mix") + + def test_empty(self): + assert traxsource._split_title_mix("") == ("", "") + + +class TestFormatArtists: + def test_single(self): + assert traxsource._format_artists(["Kapuchon"]) == "Kapuchon" + + def test_two(self): + assert traxsource._format_artists(["A", "B"]) == "A & B" + + def test_three(self): + assert traxsource._format_artists(["A", "B", "C"]) == "A, B & C" + + def test_empty(self): + assert traxsource._format_artists([]) == "" + + def test_strips_whitespace(self): + assert traxsource._format_artists([" A ", " B "]) == "A & B" + + +class TestLargeCover: + def test_substitutes_52x52_with_500x500(self): + url = "https://www.traxsource.com/scripts/image.php/52x52/abc.jpg" + assert traxsource._large_cover(url) == "https://www.traxsource.com/scripts/image.php/500x500/abc.jpg" + + def test_no_change_when_no_pattern(self): + assert traxsource._large_cover("https://example.com/x.jpg") == "https://example.com/x.jpg" + + def test_empty(self): + assert traxsource._large_cover("") == "" + + +class TestExceptions: + def test_exceptions_are_subclasses(self): + assert issubclass(traxsource.TraxsourceUnreachableError, traxsource.TraxsourceError) + assert issubclass(traxsource.TraxsourceParseError, traxsource.TraxsourceError) + + +class TestDiscoverTop100Url: + def test_extracts_link_from_genre_page(self, fixtures_dir): + html = (fixtures_dir / "traxsource_tech_house_genre.html").read_text() + url = traxsource._discover_top100_url(html) + assert url.startswith("/title/") + assert "top-100" in url + + def test_raises_when_no_link(self): + with pytest.raises(traxsource.TraxsourceParseError, match="Top 100"): + traxsource._discover_top100_url("nulla") + + +class TestParseTracks: + def test_parses_100_tracks(self, fixtures_dir): + html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text() + tracks = traxsource._parse_tracks(html) + assert len(tracks) == 100 + + def test_positions_sequential_1_to_100(self, fixtures_dir): + html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text() + tracks = traxsource._parse_tracks(html) + positions = [t.position for t in tracks] + assert positions == list(range(1, 101)) + + def test_track_shape(self, fixtures_dir): + html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text() + tracks = traxsource._parse_tracks(html) + first = tracks[0] + assert first.title + assert first.artists + assert first.traxsource_id > 0 + assert first.slug + assert first.image_url.startswith("https://") + assert first.cover_url_large.startswith("https://") + assert "500x500" in first.cover_url_large + + def test_raises_when_no_tracks(self): + with pytest.raises(traxsource.TraxsourceParseError, match="track"): + traxsource._parse_tracks("vuoto") + + +from unittest.mock import patch, MagicMock + + +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 genre_html(self, fixtures_dir): + return (fixtures_dir / "traxsource_tech_house_genre.html").read_text() + + @pytest.fixture + def top100_html(self, fixtures_dir): + return (fixtures_dir / "traxsource_tech_house_top100.html").read_text() + + def test_success_returns_100_tracks(self, genre_html, top100_html): + traxsource._cache.clear() + mock_sess = MagicMock() + mock_sess.get.side_effect = [ + _mock_response(genre_html, 200), + _mock_response(top100_html, 200), + ] + with patch("core.traxsource._session", return_value=mock_sess): + tracks = traxsource.fetch_top100("tech-house") + assert len(tracks) == 100 + + def test_invalid_slug_raises_value_error(self): + with pytest.raises(ValueError, match="slug"): + traxsource.fetch_top100("not-a-genre") + + def test_5xx_retries_and_raises_unreachable(self): + traxsource._cache.clear() + mock_sess = MagicMock() + mock_sess.get.return_value = _mock_response("", 503) + with patch("core.traxsource._session", return_value=mock_sess), \ + patch("core.traxsource.time.sleep"): + with pytest.raises(traxsource.TraxsourceUnreachableError): + traxsource.fetch_top100("tech-house") + # 3 tentativi + assert mock_sess.get.call_count == 3 + + def test_cache_hit_within_ttl(self, genre_html, top100_html): + traxsource._cache.clear() + mock_sess = MagicMock() + mock_sess.get.side_effect = [ + _mock_response(genre_html, 200), + _mock_response(top100_html, 200), + ] + with patch("core.traxsource._session", return_value=mock_sess): + traxsource.fetch_top100("tech-house") + traxsource.fetch_top100("tech-house") + # 2 chiamate al primo fetch (genre + top100), 0 al secondo + assert mock_sess.get.call_count == 2 + + def test_force_refresh_bypasses_cache(self, genre_html, top100_html): + traxsource._cache.clear() + mock_sess = MagicMock() + # 4 risposte (2 fetch x 2 richieste ciascuno) + mock_sess.get.side_effect = [ + _mock_response(genre_html, 200), + _mock_response(top100_html, 200), + _mock_response(genre_html, 200), + _mock_response(top100_html, 200), + ] + with patch("core.traxsource._session", return_value=mock_sess): + traxsource.fetch_top100("tech-house") + traxsource.fetch_top100("tech-house", force_refresh=True) + assert mock_sess.get.call_count == 4 diff --git a/webui/index.html b/webui/index.html index 67d4070..7c102c6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -33,6 +33,10 @@ 🎧 Beatport +
+ +
+
+
+
TRAXSOURCE CHARTS
+

Top 100 Traxsource per genere

+

La classifica mensile ufficiale. Scarica in un click con tag ID3 completi (label, cover art, genere).

+
+
▲
+
+ + +
+
+ + + Shift+click per bypassare la cache +
+
+
+
+ +
+
+ + + + + + + + + + + + + + +
+ +
+ + +
+
+
+
+
@@ -957,11 +1020,12 @@ - # - Artista - Titolo - Album - Durata + # + Artista + Titolo + Album + Durata + Data @@ -1015,10 +1079,11 @@ - # - Titolo video - Canale - Durata + # + Titolo video + Canale + Durata + Data diff --git a/webui/js/app.js b/webui/js/app.js index b99df9a..2ba9a1c 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -400,6 +400,7 @@ const logEls = { spotify: () => $("#spotify-log"), youtube: () => $("#youtube-log"), convert: () => $("#convert-log"), + traxsource: () => $("#traxsource-log"), }; function classifyLog(msg) { @@ -512,6 +513,9 @@ async function init() { await SpotifyUI.init(); await YoutubeUI.init(); + // Traxsource charts tab + await TraxsourceUI.init(); + // WAV -> MP3 converter tab await ConvertUI.init(); } @@ -1183,6 +1187,61 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => { try { await window.pywebview.api.open_purchase_page(); } catch (_e) {} }); +// ============================================================ +// Helper di ordinamento condivisi tra BeatportUI, SpotifyUI, YoutubeUI +// ============================================================ +function _sortPairs(tracks, existing, col, dir) { + const pairs = tracks.map((t, i) => ({ t, e: existing[i] || false, origIdx: i })); + const dirMul = dir === "desc" ? -1 : 1; + pairs.sort((a, b) => { + let av = a.t[col], bv = b.t[col]; + if (col === "position") { + // Per Beatport t.position esiste (rank ufficiale); per Spotify/YouTube + // torniamo l'ordine originale della risposta API. + av = (typeof av === "number") ? av : a.origIdx; + bv = (typeof bv === "number") ? bv : b.origIdx; + } + if (typeof av === "number" && typeof bv === "number") { + return (av - bv) * dirMul; + } + av = String(av == null ? "" : av).toLowerCase(); + bv = String(bv == null ? "" : bv).toLowerCase(); + // Empty strings sempre in fondo indipendentemente da direzione + if (av === "" && bv !== "") return 1; + if (bv === "" && av !== "") return -1; + if (av < bv) return -1 * dirMul; + if (av > bv) return 1 * dirMul; + return 0; + }); + return { + tracks: pairs.map(p => p.t), + existing: pairs.map(p => p.e), + origIndexes: pairs.map(p => p.origIdx), + }; +} + +function _bindSortableHeaders(tableSelector, onSort) { + document.querySelectorAll(`${tableSelector} th.sortable`).forEach(th => { + th.style.cursor = "pointer"; + th.style.userSelect = "none"; + th.addEventListener("click", () => onSort(th.dataset.sort)); + }); +} + +function _updateSortArrows(tableSelector, sortCol, sortDir) { + document.querySelectorAll(`${tableSelector} th.sortable`).forEach(th => { + const key = th.dataset.sort; + // Usa un per non toccare il testo principale + let arrow = th.querySelector(".sort-arrow"); + if (!arrow) { + arrow = document.createElement("span"); + arrow.className = "sort-arrow"; + th.appendChild(arrow); + } + arrow.textContent = key === sortCol ? (sortDir === "asc" ? " ▲" : " ▼") : ""; + }); +} + // ============================================================ // BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica // (il download riusa start_tracks_download, quindi passa dagli stessi @@ -1195,6 +1254,8 @@ const BeatportUI = (function () { let currentGenreName = ""; let currentGenreSlug = ""; let downloading = false; + let sortCol = null; + let sortDir = "asc"; function fmtDur(sec) { const n = Number(sec) || 0; @@ -1287,16 +1348,28 @@ const BeatportUI = (function () { const tbody = table.querySelector("tbody"); tbody.innerHTML = ""; - currentTracks.forEach((t, i) => { + let displayTracks = currentTracks; + let displayExisting = existing; + let origIndexes = currentTracks.map((_, i) => i); + if (sortCol) { + const s = _sortPairs(currentTracks, existing, sortCol, sortDir); + displayTracks = s.tracks; + displayExisting = s.existing; + origIndexes = s.origIndexes; + } + + displayTracks.forEach((t, i) => { + const origIdx = origIndexes[i]; + const alreadyDl = displayExisting[i]; const tr = document.createElement("tr"); - if (existing[i]) tr.classList.add("already-downloaded"); + if (alreadyDl) 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.dataset.idx = String(origIdx); + cb.checked = !alreadyDl; cb.addEventListener("change", updateSelectionCount); tdCheck.appendChild(cb); tr.appendChild(tdCheck); @@ -1316,7 +1389,7 @@ const BeatportUI = (function () { const tdPos = document.createElement("td"); tdPos.className = "col-pos"; - tdPos.textContent = String(t.position || (i + 1)); + tdPos.textContent = String(t.position || (origIdx + 1)); tr.appendChild(tdPos); const tdArtist = document.createElement("td"); @@ -1332,20 +1405,36 @@ const BeatportUI = (function () { tdDur.textContent = fmtDur(t.duration_sec); tr.appendChild(tdDur); + const tdDate = document.createElement("td"); + tdDate.className = "col-date"; + tdDate.textContent = t.release_date || ""; + tr.appendChild(tdDate); + const tdState = document.createElement("td"); tdState.className = "col-state"; - if (existing[i]) tdState.textContent = "✓ già scaricato"; + if (alreadyDl) tdState.textContent = "✓ già scaricato"; tr.appendChild(tdState); tbody.appendChild(tr); }); + _updateSortArrows("#beatport-table", sortCol, sortDir); table.hidden = false; $("#beatport-toolbar").hidden = false; updateOutputInfo(); updateSelectionCount(); } + function handleSort(col) { + if (sortCol === col) { + sortDir = sortDir === "asc" ? "desc" : "asc"; + } else { + sortCol = col; + sortDir = "asc"; + } + renderTable(); + } + function updateSelectionCount() { const boxes = $$("#beatport-table tbody input[type=checkbox]"); const total = boxes.length; @@ -1462,6 +1551,7 @@ const BeatportUI = (function () { $("#beatport-download-btn").addEventListener("click", startDownload); $("#beatport-stop-btn").addEventListener("click", stopDownload); $("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked)); + _bindSortableHeaders("#beatport-table", handleSort); // Aggancia agli event listener esistenti del canale "download" senza rompere // il comportamento della tab Scarica (wrapping additivo). @@ -1492,7 +1582,7 @@ const BeatportUI = (function () { const SpotifyUI = (() => { // NOTE: local module state — named `mstate` to NOT shadow the outer // module-level `state` (which holds `state.config`). - const mstate = { tracks: [], existing: [], downloading: false }; + const mstate = { tracks: [], existing: [], downloading: false, sortCol: null, sortDir: "asc" }; function _escape(s) { const d = document.createElement("div"); @@ -1530,31 +1620,55 @@ const SpotifyUI = (() => { const tbody = $("#spotify-tbody"); if (!tbody) return; tbody.innerHTML = ""; - mstate.tracks.forEach((t, i) => { - const already = !!mstate.existing[i]; + + let displayTracks = mstate.tracks; + let displayExisting = mstate.existing; + let origIndexes = mstate.tracks.map((_, i) => i); + if (mstate.sortCol) { + const s = _sortPairs(mstate.tracks, mstate.existing, mstate.sortCol, mstate.sortDir); + displayTracks = s.tracks; + displayExisting = s.existing; + origIndexes = s.origIndexes; + } + + displayTracks.forEach((t, i) => { + const origIdx = origIndexes[i]; + const already = !!displayExisting[i]; const tr = document.createElement("tr"); - tr.dataset.idx = String(i); + tr.dataset.idx = String(origIdx); if (already) tr.classList.add("already-downloaded"); const coverImg = t.image_url ? `` : ""; tr.innerHTML = ` - + ${coverImg} - ${i + 1} + ${origIdx + 1} ${_escape(t.artists)} ${_escape(t.name)} ${_escape(t.album)} ${_fmtDuration(t.duration_sec)} + ${_escape(t.release_date || "")} ${already ? "✓ già scaricato" : ""} `; const cb = tr.querySelector("input[type=checkbox]"); if (cb) cb.addEventListener("change", updateSelectionCount); tbody.appendChild(tr); }); + _updateSortArrows("#spotify-table", mstate.sortCol, mstate.sortDir); updateSelectionCount(); } + function handleSort(col) { + if (mstate.sortCol === col) { + mstate.sortDir = mstate.sortDir === "asc" ? "desc" : "asc"; + } else { + mstate.sortCol = col; + mstate.sortDir = "asc"; + } + renderTable(); + } + function setStatus(text, kind) { const el = $("#spotify-status"); if (!el) return; @@ -1708,6 +1822,7 @@ const SpotifyUI = (() => { }); $("#spotify-download-btn").addEventListener("click", startDownload); $("#spotify-stop-btn").addEventListener("click", stopDownload); + _bindSortableHeaders("#spotify-table", handleSort); // Wrap bridge handlers additivamente const origLog = bridgeHandlers["log"]; @@ -1731,7 +1846,7 @@ const SpotifyUI = (() => { // YouTube search // ===================================================================== const YoutubeUI = (() => { - const mstate = { tracks: [], existing: [], downloading: false }; + const mstate = { tracks: [], existing: [], downloading: false, sortCol: null, sortDir: "asc" }; function _escape(s) { const d = document.createElement("div"); @@ -1768,30 +1883,54 @@ const YoutubeUI = (() => { const tbody = $("#youtube-tbody"); if (!tbody) return; tbody.innerHTML = ""; - mstate.tracks.forEach((t, i) => { - const already = !!mstate.existing[i]; + + let displayTracks = mstate.tracks; + let displayExisting = mstate.existing; + let origIndexes = mstate.tracks.map((_, i) => i); + if (mstate.sortCol) { + const s = _sortPairs(mstate.tracks, mstate.existing, mstate.sortCol, mstate.sortDir); + displayTracks = s.tracks; + displayExisting = s.existing; + origIndexes = s.origIndexes; + } + + displayTracks.forEach((t, i) => { + const origIdx = origIndexes[i]; + const already = !!displayExisting[i]; const tr = document.createElement("tr"); - tr.dataset.idx = String(i); + tr.dataset.idx = String(origIdx); if (already) tr.classList.add("already-downloaded"); const coverImg = t.image_url ? `` : ""; tr.innerHTML = ` - + ${coverImg} - ${i + 1} + ${origIdx + 1} ${_escape(t.title)} ${_escape(t.channel)} ${_fmtDuration(t.duration_sec)} + ${_escape(t.release_date || "")} ${already ? "✓ già scaricato" : ""} `; const cb = tr.querySelector("input[type=checkbox]"); if (cb) cb.addEventListener("change", updateSelectionCount); tbody.appendChild(tr); }); + _updateSortArrows("#youtube-table", mstate.sortCol, mstate.sortDir); updateSelectionCount(); } + function handleSort(col) { + if (mstate.sortCol === col) { + mstate.sortDir = mstate.sortDir === "asc" ? "desc" : "asc"; + } else { + mstate.sortCol = col; + mstate.sortDir = "asc"; + } + renderTable(); + } + function setStatus(text, kind) { const el = $("#youtube-status"); if (!el) return; @@ -1934,6 +2073,7 @@ const YoutubeUI = (() => { }); $("#youtube-download-btn").addEventListener("click", startDownload); $("#youtube-stop-btn").addEventListener("click", stopDownload); + _bindSortableHeaders("#youtube-table", handleSort); const origLog = bridgeHandlers["log"]; bridgeHandlers["log"] = (payload) => { @@ -1952,6 +2092,321 @@ const YoutubeUI = (() => { return { init }; })(); +// ============================================================ +// TRAXSOURCE tab — carica Top 100 per genere, seleziona tracce, scarica +// Parallelo a BeatportUI (stessi canali "download:progress" / +// "download:done" / log view="download"). Colonne: cover, #, artista, +// titolo (mix), label, stato. No Durata / Data (non nel markup Traxsource). +// ============================================================ +const TraxsourceUI = (function () { + let genres = []; + let currentTracks = []; + let existing = []; + let currentGenreName = ""; + let currentGenreSlug = ""; + let downloading = false; + let sortCol = null; + let sortDir = "asc"; + + function safeGenreFolder(name) { + return String(name || "").replace(/[\/\\]/g, "_").trim(); + } + + function populateSelect() { + const sel = $("#traxsource-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 = $("#traxsource-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}/Traxsource_${folder}` + : `Destinazione: ${root}/Traxsource`; + info.className = "beatport-output-info"; + } + + function setStatus(text, kind) { + const el = $("#traxsource-status"); + if (!el) return; + el.textContent = text || ""; + el.className = "beatport-status" + (kind ? " " + kind : ""); + } + + async function loadChart(forceRefresh) { + const sel = $("#traxsource-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"); + $("#traxsource-table").hidden = true; + $("#traxsource-toolbar").hidden = true; + $("#traxsource-table").querySelector("tbody").innerHTML = ""; + + let res; + try { + res = await window.pywebview.api.traxsource_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 = "Traxsource 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.traxsource_check_existing(currentTracks, currentGenreName); + } catch (_e) { + existing = currentTracks.map(() => false); + } + setStatus(`${currentTracks.length} brani caricati`, "ok"); + renderTable(); + } + + function renderTable() { + const table = $("#traxsource-table"); + const tbody = table.querySelector("tbody"); + tbody.innerHTML = ""; + + let displayTracks = currentTracks; + let displayExisting = existing; + let origIndexes = currentTracks.map((_, i) => i); + if (sortCol) { + const s = _sortPairs(currentTracks, existing, sortCol, sortDir); + displayTracks = s.tracks; + displayExisting = s.existing; + origIndexes = s.origIndexes; + } + + displayTracks.forEach((t, i) => { + const origIdx = origIndexes[i]; + const alreadyDl = displayExisting[i]; + const tr = document.createElement("tr"); + if (alreadyDl) 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(origIdx); + cb.checked = !alreadyDl; + cb.addEventListener("change", updateSelectionCount); + tdCheck.appendChild(cb); + tr.appendChild(tdCheck); + + const tdCover = document.createElement("td"); + tdCover.className = "col-cover"; + if (t.image_url) { + const img = document.createElement("img"); + img.src = t.image_url; + img.alt = ""; + img.loading = "lazy"; + img.referrerPolicy = "no-referrer"; + img.onerror = () => { img.style.visibility = "hidden"; }; + tdCover.appendChild(img); + } + tr.appendChild(tdCover); + + const tdPos = document.createElement("td"); + tdPos.className = "col-pos"; + tdPos.textContent = String(t.position || (origIdx + 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 tdLabel = document.createElement("td"); + tdLabel.textContent = t.label || ""; + tr.appendChild(tdLabel); + + const tdState = document.createElement("td"); + tdState.className = "col-state"; + if (alreadyDl) tdState.textContent = "✓ già scaricato"; + tr.appendChild(tdState); + + tbody.appendChild(tr); + }); + + _updateSortArrows("#traxsource-table", sortCol, sortDir); + table.hidden = false; + $("#traxsource-toolbar").hidden = false; + updateOutputInfo(); + updateSelectionCount(); + } + + function handleSort(col) { + if (sortCol === col) { + sortDir = sortDir === "asc" ? "desc" : "asc"; + } else { + sortCol = col; + sortDir = "asc"; + } + renderTable(); + } + + function updateSelectionCount() { + const boxes = $$("#traxsource-table tbody input[type=checkbox]"); + const total = boxes.length; + const checked = boxes.filter((b) => b.checked).length; + $("#traxsource-selected-count").textContent = `${checked}/${total} selezionati`; + const dlBtn = $("#traxsource-download-btn"); + if (dlBtn) dlBtn.disabled = downloading || checked === 0; + + const master = $("#traxsource-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) { + $$("#traxsource-table tbody input[type=checkbox]").forEach((b) => { b.checked = checked; }); + updateSelectionCount(); + } + + function appendTraxsourceLog(msg) { + const el = $("#traxsource-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 = $$("#traxsource-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; + $("#traxsource-download-btn").disabled = true; + $("#traxsource-stop-btn").hidden = false; + $("#traxsource-stop-btn").disabled = false; + $("#traxsource-log").innerHTML = ""; + appendTraxsourceLog(`[INFO] Avvio download di ${selected.length} brani da ${currentGenreName}…`); + + let res; + try { + res = await window.pywebview.api.traxsource_download_selected(selected, currentGenreName); + } catch (e) { + appendTraxsourceLog("[ERRORE] " + ((e && e.message) || e)); + finishDownload(); + return; + } + if (!res || !res.ok) { + const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download"; + appendTraxsourceLog("[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; + $("#traxsource-stop-btn").hidden = true; + $("#traxsource-stop-btn").disabled = true; + updateSelectionCount(); + if (currentTracks.length) { + window.pywebview.api.traxsource_check_existing(currentTracks, currentGenreName) + .then((res) => { existing = res || existing; renderTable(); }) + .catch(() => {}); + } + } + + async function init() { + try { + genres = await window.pywebview.api.traxsource_genres(); + } catch (e) { + console.error("Traxsource: impossibile caricare i generi", e); + genres = []; + } + populateSelect(); + + const last = (state.config && state.config.traxsource_last_genre) || ""; + const sel = $("#traxsource-genre"); + if (sel && last && genres.some((g) => g.slug === last)) { + sel.value = last; + } + updateOutputInfo(); + + $("#traxsource-load-btn").addEventListener("click", (e) => loadChart(e.shiftKey)); + $("#traxsource-download-btn").addEventListener("click", startDownload); + $("#traxsource-stop-btn").addEventListener("click", stopDownload); + $("#traxsource-select-all").addEventListener("change", (e) => toggleAll(e.target.checked)); + _bindSortableHeaders("#traxsource-table", handleSort); + + // Wrapping additivo sui bridgeHandlers (canale "download" condiviso) + const origProgress = bridgeHandlers["download:progress"]; + bridgeHandlers["download:progress"] = (p) => { + if (origProgress) origProgress(p); + }; + 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") appendTraxsourceLog(msg); + }; + } + + return { init }; +})(); + // ===================================================================== // Converti (WAV -> MP3) // =====================================================================