35 KiB
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/<id>/<slug> → estrai link /title/<id>/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 annotationsin 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
beautifulsoup4a 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)
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:
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:
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
python3 -c "
import re
html = open('tests/fixtures/traxsource_tech_house_top100.html').read()
# Trova primo blocco track
m = re.search(r'<div data-trid=\"\d+\" class=\"top-item play-trk[^>]*>.{0,1500}', html, re.DOTALL)
print(m.group(0)[:1000] if m else 'NO MATCH')
"
Aspettato: markup con <a class=\"com-title\">, <a class=\"com-artists\">, <a class=\"com-label\">, <div class=\"ttib position\">, <img src=\"...52x52/...jpg\">.
Se qualche selettore è cambiato, ANNOTA e adatta i Task 4-5 di conseguenza.
- Step 5: Script refresh genres
Crea scripts/refresh_traxsource_genres.py:
"""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/<id>/<slug>"
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:
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
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
"""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.pyminimo
"""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
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
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:
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 -vExpected: ~17 test PASS. -
Step 5: Commit
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:
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("<html>nulla</html>")
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("<html>vuoto</html>")
-
Step 2: Verifica fail
-
Step 3: Implementa
Aggiungi in core/traxsource.py:
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
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)
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):
import time
Aggiungi in fondo:
_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 -3Expected: ~28 test verdi totali (5 preesistenti + nuovi). -
Step 5: Commit
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"inVERSION = "v1.9.0". - Step 2: Nel dict
DEFAULTS, aggiungi dopobeatport_last_genre:
"traxsource_last_genre": "tech-house",
- Step 3: Verifica:
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:
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 traxsourceagli import
Trova la sezione from core import beatport, spotify_client e aggiungi traxsource:
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_<genre> invece di Beatport_<genre>) + i campi metadata (album=label per Traxsource, non nome release).
# ================================================================
# 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
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
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:
<button class="nav-item" data-view="traxsource" data-feature="audio">
<span class="nav-icon">▲</span>
<span>Traxsource</span>
</button>
Dopo <section id="view-beatport">…</section>, aggiungi (copia adattata):
<section class="view" id="view-traxsource">
<header class="hero hero-purple">
<div class="hero-content">
<div class="hero-eyebrow purple">TRAXSOURCE</div>
<h1 class="hero-title">Top 100 Traxsource per genere</h1>
<p class="hero-subtitle">La classifica mensile ufficiale · Scarica in un click con tag ID3 completi</p>
</div>
<div class="hero-deco">▲</div>
</header>
<section class="card">
<div class="beatport-header">
<label style="display:flex;align-items:center;gap:8px;">
Genere:
<select id="traxsource-genre" class="input"></select>
</label>
<button id="traxsource-load-btn" class="btn btn-primary pill">▶ Carica Top 100</button>
<span class="hint-inline">Shift+click per forzare refresh (bypass cache 15min)</span>
</div>
<div id="traxsource-status" class="beatport-status"></div>
<div id="traxsource-output-info" class="beatport-output-info"></div>
<div class="beatport-table-card" id="traxsource-table-wrap" hidden>
<div class="beatport-table-wrap">
<table id="traxsource-table" class="beatport-table">
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="traxsource-select-all" /></th>
<th class="col-cover"></th>
<th class="col-pos sortable" data-sort="position">#</th>
<th class="sortable" data-sort="artists">Artista</th>
<th class="sortable" data-sort="title">Titolo (Mix)</th>
<th class="sortable" data-sort="label">Label</th>
<th class="col-state">Stato</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="beatport-toolbar" id="traxsource-toolbar" hidden>
<span class="counter" id="traxsource-selected-count">0/0 selezionati</span>
<button id="traxsource-download-btn" class="btn btn-primary pill" disabled>▶ Scarica selezionati</button>
<button id="traxsource-stop-btn" class="btn btn-danger pill" hidden>■ Interrompi</button>
</div>
</section>
<section class="log-card">
<div class="section-label">LOG</div>
<div id="traxsource-log" class="log"></div>
</section>
</section>
- 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:
E rimuovi la colonna Durata (Traxsource non ha duration nel markup).
const tdLabel = document.createElement("td"); tdLabel.textContent = t.label || ""; tr.appendChild(tdLabel); - Nel
updateOutputInfo, cartellaTraxsource_<genre>invece diBeatport/<genre>.
Aggiungi traxsource a logEls:
traxsource: () => document.getElementById("traxsource-log"),
Aggiungi bootstrap:
Trova await BeatportUI.init(); in init() principale (~riga 508), aggiungi dopo:
await TraxsourceUI.init();
- Step 3: Verifiche
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)
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.mdcon descrizione feature - Step 4: Merge branch, tag
v1.9.0, push. STOP prima del push per conferma utente (protocollo release del repo — vedi memorymusictools-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 headerSec-Fetch-*a mano. Se ancora bloccato: rotareimpersonate(chrome,chrome124,safari17_2) e vedere quale passa - CSS class name cambiati: BeautifulSoup fallisce silenzioso su selettori inesistenti →
_parse_trackssollevaTraxsourceParseError("nessuna div.top-item.play-trk trovata"). Ispezionatests/fixtures/traxsource_tech_house_top100.htmlper 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)