From c037da4901e954cef51416f057632cfa3e1b6920 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:03:33 +0200 Subject: [PATCH 01/15] test: setup pytest infra per feature Beatport --- requirements.txt | 5 +++++ tests/__init__.py | 0 tests/conftest.py | 12 ++++++++++++ 3 files changed, 17 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py diff --git a/requirements.txt b/requirements.txt index 666209b..8f16531 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,8 @@ pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin" pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin" pythonnet==3.0.5 ; sys_platform == "win32" clr-loader==0.2.7.post0 ; sys_platform == "win32" + +# --- dev only --- +pytest>=8.0.0 +responses>=0.25.0 +freezegun>=1.4.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3ea5917 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures pytest condivise.""" +from pathlib import Path + +import pytest + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def fixtures_dir() -> Path: + return FIXTURES_DIR From e1aaa83fe94492a4148fd4889721027d3c4d0a4b Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:14:55 +0200 Subject: [PATCH 02/15] beatport: fixture HTML + curl_cffi + script refresh generi --- requirements.txt | 1 + scripts/refresh_beatport_genres.py | 88 +++++ tests/fixtures/beatport_melodic_top100.html | 357 ++++++++++++++++++++ 3 files changed, 446 insertions(+) create mode 100644 scripts/refresh_beatport_genres.py create mode 100644 tests/fixtures/beatport_melodic_top100.html diff --git a/requirements.txt b/requirements.txt index 8f16531..2edd217 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin" 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 # --- dev only --- pytest>=8.0.0 diff --git a/scripts/refresh_beatport_genres.py b/scripts/refresh_beatport_genres.py new file mode 100644 index 0000000..69a0971 --- /dev/null +++ b/scripts/refresh_beatport_genres.py @@ -0,0 +1,88 @@ +"""Estrae lo slug + numeric id di tutti i generi Beatport. +Uso: python3 scripts/refresh_beatport_genres.py > /tmp/genres.txt +Poi copia manualmente in core/beatport.py::GENRES. + +Nota: la pagina /genres (task originale) restituisce 404 sull'attuale sito +Beatport. Aggreghiamo quindi la lista dei generi dalla pagina /charts, dove +ogni featured chart include un array `genres` completo di slug + id + name. +""" + +from __future__ import annotations + +import re +import json +import sys +from curl_cffi import requests + + +GENRES_URL = "https://www.beatport.com/genres" +CHARTS_URL = "https://www.beatport.com/charts" + + +def _fetch_next_data(url: str) -> dict: + resp = requests.get(url, impersonate="chrome131", timeout=15) + resp.raise_for_status() + m = re.search( + r'', resp.text, re.DOTALL + ) + if not m: + raise RuntimeError(f"__NEXT_DATA__ non trovato su {url}") + return json.loads(m.group(1)) + + +def _collect_genres(node, out: dict) -> None: + """Raccoglie ricorsivamente ogni dict con chiavi slug/id/name che appare + dentro un array chiamato "genres".""" + if isinstance(node, dict): + for k, v in node.items(): + if ( + k == "genres" + and isinstance(v, list) + and v + and all( + isinstance(x, dict) and "slug" in x and "id" in x and "name" in x + for x in v + ) + ): + for g in v: + out[g["id"]] = (g["slug"], g["name"]) + else: + _collect_genres(v, out) + elif isinstance(node, list): + for item in node: + _collect_genres(item, out) + + +def main() -> int: + # Prova /genres (URL originale). Se 404 o schema inatteso, cade su /charts. + try: + data = _fetch_next_data(GENRES_URL) + queries = data["props"]["pageProps"]["dehydratedState"]["queries"] + for q in queries: + d = q.get("state", {}).get("data", {}) + if ( + isinstance(d, dict) + and "results" in d + and isinstance(d["results"], list) + ): + for g in d["results"]: + if "slug" in g and "id" in g and "name" in g: + print(f' "{g["slug"]}": ({g["id"]}, "{g["name"]}"),') + return 0 + except Exception as e: + print(f"# /genres non disponibile ({e}), fallback su /charts", file=sys.stderr) + + data = _fetch_next_data(CHARTS_URL) + genres: dict = {} + _collect_genres(data, genres) + if not genres: + print("nessun genere trovato su /charts", file=sys.stderr) + return 1 + for gid in sorted(genres): + slug, name = genres[gid] + print(f' "{slug}": ({gid}, "{name}"),') + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/beatport_melodic_top100.html b/tests/fixtures/beatport_melodic_top100.html new file mode 100644 index 0000000..95687f8 --- /dev/null +++ b/tests/fixtures/beatport_melodic_top100.html @@ -0,0 +1,357 @@ +Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport
Add to queue
Add to playlist
Melodic House & Techno
 
Title / Artists
Label / Remixers
Subgenre / BPM & Key
Released
 
Toxic
1
Add to queue
Add to playlist
125 BPM - C Minor
2026-07-03
Smalltown BoyExclusive
2
Add to queue
Add to playlist
125 BPM - C Major
2026-07-03
Here In My Arms (Enjoy The Silence)
3
Add to queue
Add to playlist
132 BPM - C Minor
2026-07-10
Come To Life
4
Add to queue
Add to playlist
130 BPM - D Major
2026-07-10
Coming HomeExclusive
5
Add to queue
Add to playlist
126 BPM - F Minor
2026-07-10
I See U EP
6
Add to queue
Add to playlist
122 BPM - F Major
2026-06-12
Not Exactly (Rinzen Remix Extended)
7
Add to queue
Add to playlist
128 BPM - F Major
2026-06-19
Ghost Dance
8
Add to queue
Add to playlist
125 BPM - C Minor
2025-12-26
Hollow Sun
9
Add to queue
Add to playlist
127 BPM - F Major
2026-07-10
Hollow Sun
10
Add to queue
Add to playlist
127 BPM - A Minor
2026-07-10
No Seatbelt
11
Add to queue
Add to playlist
Melodic House
123 BPM - A Minor
2026-07-10
Toxic
12
Add to queue
Add to playlist
127 BPM - Bb Major
2026-07-03
Take You There
13
Add to queue
Add to playlist
127 BPM - C Major
2026-06-19
Attract
14
Add to queue
Add to playlist
123 BPM - D Minor
2026-06-19
Set You FreeExclusive
15
Add to queue
Add to playlist
131 BPM - F Minor
2026-07-03
The Rapture Pt.III
16
Add to queue
Add to playlist
120 BPM - E Minor
2023-06-09
Bad Choices - Extended
17
Add to queue
Add to playlist
130 BPM - Db Major
2026-06-19
Science Fiction
18
Add to queue
Add to playlist
127 BPM - D Minor
2026-03-06
Heart Spin EP
19
Add to queue
Add to playlist
Melodic House
120 BPM - B Major
2026-05-29
Blue (Extended Mix)Exclusive
20
Add to queue
Add to playlist
123 BPM - D Minor
2026-06-05
In The Night (Extended Mix)
21
Add to queue
Add to playlist
128 BPM - Gb Major
2026-06-26
Be The One
22
Add to queue
Add to playlist
123 BPM - F Minor
2026-04-24
Feel So Right
23
Add to queue
Add to playlist
124 BPM - Gb Minor
2026-04-10
SAME MAN (feat. True Motion)
24
Add to queue
Add to playlist
124 BPM - F Minor
2026-06-05
The Shift (Extended Mix)
25
Add to queue
Add to playlist
124 BPM - Gb Major
2026-07-03
How Will I Know?
26
Add to queue
Add to playlist
Melodic House
127 BPM - C Major
2026-07-07
Dream
27
Add to queue
Add to playlist
Melodic House
120 BPM - B Minor
2026-06-26
In the Moment (Adriatique Extended Remix)
28
Add to queue
Add to playlist
124 BPM - Bb Minor
2025-04-04
Horizon (Eelke Kleijn Remix)Exclusive
29
Add to queue
Add to playlist
Melodic House
127 BPM - C Minor
2026-07-10
The Present
30
Add to queue
Add to playlist
Melodic House
128 BPM - B Minor
2026-06-26
No Rules EP
31
Add to queue
Add to playlist
124 BPM - Ab Major
2026-05-01
Recall (Extended Mix)Exclusive
32
Add to queue
Add to playlist
122 BPM - E Major
2026-04-10
Hot Sauce (Extended)Exclusive
33
Add to queue
Add to playlist
129 BPM - Gb Major
2026-05-01
Red In The Desert (Joseph Capriati Remix)
34
Add to queue
Add to playlist
130 BPM - E Minor
2026-06-26
Heart Spin EP
35
Add to queue
Add to playlist
Melodic House
122 BPM - Bb Minor
2026-05-29
Western Dream Remixed (Extended)
36
Add to queue
Add to playlist
123 BPM - F Major
2026-07-03
Quantum Echoes Pt. I
37
Add to queue
Add to playlist
128 BPM - D Major
2025-09-19
Messy (Extended)
38
Add to queue
Add to playlist
Messy Extended
This media contains explicit material.
120 BPM - Gb Minor
2026-06-25
Voodoo Doll (Extended Mix)Exclusive
39
Add to queue
Add to playlist
124 BPM - Db Major
2026-07-03
NO SLEEP
41
Add to queue
Add to playlist
Melodic House
130 BPM - E Major
2026-06-19
Trippy Yeah
42
Add to queue
Add to playlist
123 BPM - A Major
2025-06-27
More Love  (Rampa &ME Remix)
43
Add to queue
Add to playlist
122 BPM - B Minor
2023-10-27
Secret Weapons Part 18
44
Add to queue
Add to playlist
122 BPM - Eb Minor
2026-05-22
Secret Weapons Part 18
45
Add to queue
Add to playlist
155 BPM - D Major
2026-05-22
Beg You
46
Add to queue
Add to playlist
128 BPM - D Minor
2026-06-26
Annihilation (Extended Mix)
47
Add to queue
Add to playlist
130 BPM - D Minor
2026-05-22
No MoreExclusive
49
Add to queue
Add to playlist
124 BPM - B Minor
2026-07-10
Saying Hey! (Extended Mix)
50
Add to queue
Add to playlist
125 BPM - Bb Minor
2026-06-05
Didn't Miss You
51
Add to queue
Add to playlist
Melodic House
122 BPM - F Minor
2026-05-01
UH HUH
52
Add to queue
Add to playlist
123 BPM - D Major
2026-02-06
We Are The People (ARTBAT Extended Remix)
53
Add to queue
Add to playlist
125 BPM - E Minor
2024-12-06
Blackout
54
Add to queue
Add to playlist
128 BPM - E Minor
2026-06-19
Waitin' (Extended Mix)
55
Add to queue
Add to playlist
122 BPM - Gb Major
2025-10-10
Aria
56
Add to queue
Add to playlist
122 BPM - C Major
2023-09-22
Rivers and Homes Retake
57
Add to queue
Add to playlist
127 BPM - Ab Major
2026-04-24
At Night - Anyma x Layton Giordani Extended Remix
58
Add to queue
Add to playlist
127 BPM - F Major
2025-06-27
Voices In My Head
59
Add to queue
Add to playlist
128 BPM - D Major
2025-02-14
The Only One
60
Add to queue
Add to playlist
Melodic House
122 BPM - D Minor
2026-05-15
EVERYTHING IN ITS RIGHT PLACE
61
Add to queue
Add to playlist
124 BPM - C Major
2026-01-30
DIGITAL ANIMAL
62
Add to queue
Add to playlist
128 BPM - F Major
2026-07-10
Touch Me
63
Add to queue
Add to playlist
124 BPM - D Major
2026-05-29
Lumen (Extended Mix)Exclusive
64
Add to queue
Add to playlist
120 BPM - C Minor
2026-07-03
Dark Matter Extended Remixes
65
Add to queue
Add to playlist
126 BPM - A Minor
2025-12-12
Yosemite (Extended)
66
Add to queue
Add to playlist
144 BPM - Ab Major
2024-12-13
Return to Oz (ARTBAT Remix)
67
Add to queue
Add to playlist
124 BPM - D Minor
2019-05-17
Exhale EP
68
Add to queue
Add to playlist
Melodic House
126 BPM - C Minor
2026-07-10
Feel Good (Extended Mix)Exclusive
69
Add to queue
Add to playlist
128 BPM - C Major
2026-07-03
Crazy For It
70
Add to queue
Add to playlist
120 BPM - Ab Minor
2025-05-30
Miracle - RÜFÜS DU SOL Remix
71
Add to queue
Add to playlist
123 BPM - C Major
2023-10-13
Ain't Changin
72
Add to queue
Add to playlist
122 BPM - B Minor
2026-07-10
Trainers OnExclusive
73
Add to queue
Add to playlist
125 BPM - Eb Major
2026-07-03
Heavenly (Extended Mix)Exclusive
74
Add to queue
Add to playlist
124 BPM - Ab Minor
2026-06-26
B4U & Tripidi
75
Add to queue
Add to playlist
120 BPM - Bb Minor
2026-06-26
Reflections (Oliver Koletzki Remix)
76
Add to queue
Add to playlist
127 BPM - F Major
2026-06-19
CloserExclusive
78
Add to queue
Add to playlist
125 BPM - C Major
2026-04-03
Tell Me Why - MEDUZA Remix
79
Add to queue
Add to playlist
126 BPM - B Major
2022-09-02
Hey Bro
80
Add to queue
Add to playlist
127 BPM - C Major
2026-06-26
MANTRA
81
Add to queue
Add to playlist
Melodic House
128 BPM - C Minor
2026-05-15
Pa Ca
82
Add to queue
Add to playlist
128 BPM - B Major
2026-05-01
Turn Up The Dose (Extended Mix)
83
Add to queue
Add to playlist
130 BPM - D Major
2026-04-10
Surrender (Extended Version)Exclusive
84
Add to queue
Add to playlist
125 BPM - A Minor
2026-07-09
I See U EP
85
Add to queue
Add to playlist
120 BPM - E Major
2026-06-12
1886
86
Add to queue
Add to playlist
122 BPM - E Major
2026-04-10
Rock Like This EP
87
Add to queue
Add to playlist
125 BPM - D Minor
2026-04-17
Does It Matter (Ajna Remix)
88
Add to queue
Add to playlist
120 BPM - G Minor
2023-05-19
Pjanoo
89
Add to queue
Add to playlist
126 BPM - G Minor
2008-08-29
Technicolor (Extended Mix)
90
Add to queue
Add to playlist
124 BPM - D Minor
2026-06-19
Late at Night (Extended Mix)
91
Add to queue
Add to playlist
129 BPM - D Major
2026-03-12
Come AliveExclusive
92
Add to queue
Add to playlist
125 BPM - Eb Major
2026-07-10
Sweat
93
Add to queue
Add to playlist
124 BPM - D Minor
2026-06-18
Ghosts (Extended Mix)
94
Add to queue
Add to playlist
130 BPM - E Major
2026-05-29
Cloudy Eyes (Dance Tonight)
95
Add to queue
Add to playlist
122 BPM - F Minor
2025-09-26
The Spot
96
Add to queue
Add to playlist
128 BPM - C Minor
2025-06-20
Feel The Same
97
Add to queue
Add to playlist
Melodic House
125 BPM - Gb Major
2026-07-03
Spotlight
98
Add to queue
Add to playlist
122 BPM - Eb Minor
2026-04-24
Lazer Beams Remixes
99
Add to queue
Add to playlist
127 BPM - E Major
2026-04-10
Giv Me Luv (Remixes 26)
100
Add to queue
Add to playlist
126 BPM - G Major
2026-02-27
\ No newline at end of file From 70cdfe89834967921d04822e90d5e47b6599dacd Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:17:09 +0200 Subject: [PATCH 03/15] beatport: GENRES + list_genres() con test --- core/beatport.py | 56 ++++++++++++++++++++++++++++++++++++++++++ tests/test_beatport.py | 29 ++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 core/beatport.py create mode 100644 tests/test_beatport.py diff --git a/core/beatport.py b/core/beatport.py new file mode 100644 index 0000000..addb705 --- /dev/null +++ b/core/beatport.py @@ -0,0 +1,56 @@ +"""Fetch Top 100 Beatport per genere. + +Approccio: estrai il JSON `__NEXT_DATA__` dal HTML della pagina Next.js. +Bypass Cloudflare via curl_cffi (TLS impersonation). +Vedi docs/superpowers/specs/2026-07-14-beatport-charts-design.md. +""" + +from __future__ import annotations + + +# Mappa slug URL Beatport -> (numeric_id, display_name) +# Enumerata via scripts/refresh_beatport_genres.py (Task 1). +# Copia dell'output di /tmp/genres_output.txt (31 generi). +GENRES: dict = { + "drum-bass": (1, "Drum & Bass"), + "hard-techno": (2, "Hard Techno"), + "electronica": (3, "Electronica"), + "house": (5, "House"), + "techno-peak-time-driving": (6, "Techno (Peak Time / Driving)"), + "trance-main-floor": (7, "Trance (Main Floor)"), + "hard-dance-hardcore-neo-rave": (8, "Hard Dance / Hardcore / Neo Rave"), + "breaks-breakbeat-uk-bass": (9, "Breaks / Breakbeat / UK Bass"), + "tech-house": (11, "Tech House"), + "deep-house": (12, "Deep House"), + "psy-trance": (13, "Psy-Trance"), + "minimal-deep-tech": (14, "Minimal / Deep Tech"), + "progressive-house": (15, "Progressive House"), + "dubstep": (18, "Dubstep"), + "indie-dance": (37, "Indie Dance"), + "trap-future-bass": (38, "Trap / Future Bass"), + "dance-pop": (39, "Dance / Pop"), + "nu-disco-disco": (50, "Nu Disco / Disco"), + "funky-house": (81, "Funky House"), + "bass-club": (85, "Bass / Club"), + "uk-garage-bassline": (86, "UK Garage / Bassline"), + "afro-house": (89, "Afro House"), + "melodic-house-techno": (90, "Melodic House & Techno"), + "bass-house": (91, "Bass House"), + "techno-raw-deep-hypnotic": (92, "Techno (Raw / Deep / Hypnotic)"), + "organic-house": (93, "Organic House"), + "electro-classic-detroit-modern": (94, "Electro (Classic / Detroit / Modern)"), + "140-deep-dubstep-grime": (95, "140 / Deep Dubstep / Grime"), + "mainstage": (96, "Mainstage"), + "jackin-house": (97, "Jackin House"), + "trance-raw-deep-hypnotic": (99, "Trance (Raw / Deep / Hypnotic)"), +} + + +def list_genres() -> list: + """Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name.""" + result = [ + {"slug": slug, "id": gid, "name": name} + for slug, (gid, name) in GENRES.items() + ] + result.sort(key=lambda g: g["name"].casefold()) + return result diff --git a/tests/test_beatport.py b/tests/test_beatport.py new file mode 100644 index 0000000..b68dd40 --- /dev/null +++ b/tests/test_beatport.py @@ -0,0 +1,29 @@ +"""Test per core.beatport.""" + +from __future__ import annotations + +import pytest + +from core import beatport + + +class TestListGenres: + def test_returns_list_of_dicts(self): + result = beatport.list_genres() + assert isinstance(result, list) + assert len(result) >= 10 # almeno 10 generi + 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 = beatport.list_genres() + names = [g["name"] for g in result] + assert names == sorted(names, key=str.casefold) + + def test_melodic_house_techno_present(self): + result = beatport.list_genres() + slugs = [g["slug"] for g in result] + assert "melodic-house-techno" in slugs From 4221a5992b2f26a84019d228302f80cc85f594d8 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:18:38 +0200 Subject: [PATCH 04/15] beatport: BeatportTrack dataclass con display + spotify_query --- core/beatport.py | 25 +++++++++++++++++++++++++ tests/test_beatport.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/core/beatport.py b/core/beatport.py index addb705..890c6e1 100644 --- a/core/beatport.py +++ b/core/beatport.py @@ -7,6 +7,31 @@ Vedi docs/superpowers/specs/2026-07-14-beatport-charts-design.md. from __future__ import annotations +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BeatportTrack: + position: int + title: str + mix: str # es. "Extended Mix", "Original Mix", "" + artists: str # es. "A, B & C" già formattato + duration_sec: int + beatport_id: int + + @property + def display(self) -> str: + """Formato testo compatibile coi file .txt curati a mano: + 'Artista – Titolo (Mix) (M:SS)'.""" + m, s = divmod(self.duration_sec, 60) + return f"{self.artists} – {self.title} ({self.mix}) ({m}:{s:02d})" + + @property + def spotify_query(self) -> str: + """Query pura per la search Spotify (senza mix name, che disturba + il matching su titoli tipo 'Extended Mix').""" + return f"{self.artists} {self.title}" + # Mappa slug URL Beatport -> (numeric_id, display_name) # Enumerata via scripts/refresh_beatport_genres.py (Task 1). diff --git a/tests/test_beatport.py b/tests/test_beatport.py index b68dd40..fb4ddd5 100644 --- a/tests/test_beatport.py +++ b/tests/test_beatport.py @@ -27,3 +27,42 @@ class TestListGenres: result = beatport.list_genres() slugs = [g["slug"] for g in result] assert "melodic-house-techno" in slugs + + +class TestBeatportTrack: + def test_display_format(self): + t = beatport.BeatportTrack( + position=1, + title="Hot Sauce", + mix="Extended", + artists="Kapuchon, Miss Monique & GLZ", + duration_sec=336, + beatport_id=12345, + ) + assert t.display == "Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)" + + def test_display_pads_seconds(self): + t = beatport.BeatportTrack( + position=1, title="X", mix="Y", artists="A", + duration_sec=65, beatport_id=1, + ) + assert t.display.endswith("(1:05)") + + def test_spotify_query(self): + t = beatport.BeatportTrack( + position=1, + title="Hot Sauce", + mix="Extended", + artists="Kapuchon, Miss Monique & GLZ", + duration_sec=336, + beatport_id=12345, + ) + assert t.spotify_query == "Kapuchon, Miss Monique & GLZ Hot Sauce" + + def test_is_frozen(self): + t = beatport.BeatportTrack( + position=1, title="X", mix="Y", artists="A", + duration_sec=1, beatport_id=1, + ) + with pytest.raises(Exception): + t.title = "Z" # frozen=True impedisce mutazione From f1e57905c1a0b1cee4ece8c0cc1caf5106c15126 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:20:21 +0200 Subject: [PATCH 05/15] beatport: estrazione __NEXT_DATA__ + eccezioni tipizzate --- core/beatport.py | 31 +++++++++++++++++++++++++++++++ tests/test_beatport.py | 17 +++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/core/beatport.py b/core/beatport.py index 890c6e1..8910548 100644 --- a/core/beatport.py +++ b/core/beatport.py @@ -7,6 +7,8 @@ Vedi docs/superpowers/specs/2026-07-14-beatport-charts-design.md. from __future__ import annotations +import json +import re from dataclasses import dataclass @@ -33,6 +35,35 @@ class BeatportTrack: return f"{self.artists} {self.title}" +class BeatportError(Exception): + """Base per errori Beatport.""" + + +class BeatportUnreachableError(BeatportError): + """Rete o server Beatport non raggiungibile / 5xx.""" + + +class BeatportParseError(BeatportError): + """HTML/JSON ricevuto ma non conforme allo schema atteso.""" + + +_NEXT_DATA_RE = re.compile( + r'', + re.DOTALL, +) + + +def _extract_next_data(html: str) -> dict: + """Estrae il payload JSON dallo script <__NEXT_DATA__> di Next.js.""" + m = _NEXT_DATA_RE.search(html) + if not m: + raise BeatportParseError("__NEXT_DATA__ non trovato nella pagina Beatport") + try: + return json.loads(m.group(1)) + except json.JSONDecodeError as e: + raise BeatportParseError(f"__NEXT_DATA__ JSON malformato: {e}") from e + + # Mappa slug URL Beatport -> (numeric_id, display_name) # Enumerata via scripts/refresh_beatport_genres.py (Task 1). # Copia dell'output di /tmp/genres_output.txt (31 generi). diff --git a/tests/test_beatport.py b/tests/test_beatport.py index fb4ddd5..1e533dd 100644 --- a/tests/test_beatport.py +++ b/tests/test_beatport.py @@ -66,3 +66,20 @@ class TestBeatportTrack: ) with pytest.raises(Exception): t.title = "Z" # frozen=True impedisce mutazione + + +class TestExtractNextData: + def test_extracts_json_from_valid_html(self, fixtures_dir): + html = (fixtures_dir / "beatport_melodic_top100.html").read_text() + data = beatport._extract_next_data(html) + assert isinstance(data, dict) + assert "props" in data + + def test_missing_script_raises(self): + with pytest.raises(beatport.BeatportParseError, match="__NEXT_DATA__ non trovato"): + beatport._extract_next_data("nulla") + + def test_malformed_json_raises(self): + broken = '' + with pytest.raises(beatport.BeatportParseError, match="JSON malformato"): + beatport._extract_next_data(broken) From 13c8e607127eb05932834521b429099e57776204 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:22:21 +0200 Subject: [PATCH 06/15] =?UTF-8?q?beatport:=20parse=20JSON=20=E2=86=92=20li?= =?UTF-8?q?st[BeatportTrack]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/beatport.py | 56 ++++++++++++++++++++++++++++++++++++++++++ tests/test_beatport.py | 28 +++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/core/beatport.py b/core/beatport.py index 8910548..a4d5ff2 100644 --- a/core/beatport.py +++ b/core/beatport.py @@ -110,3 +110,59 @@ def list_genres() -> list: ] result.sort(key=lambda g: g["name"].casefold()) return result + + +def _find_tracks_results(data: dict) -> list: + """Cerca dentro le queries dehydrated il primo `results` che ha almeno 50 elementi + e la shape di una track (chiave `id` presente).""" + try: + queries = data["props"]["pageProps"]["dehydratedState"]["queries"] + except (KeyError, TypeError) as e: + raise BeatportParseError( + f"schema JSON inatteso, `results` non localizzabile (chiave mancante: {e})" + ) from e + + for q in queries: + state_data = (q or {}).get("state", {}).get("data") + if not isinstance(state_data, dict): + continue + results = state_data.get("results") + if isinstance(results, list) and len(results) >= 50: + if results and isinstance(results[0], dict) and "id" in results[0]: + return results + raise BeatportParseError("nessun `results` di 50+ track trovato in __NEXT_DATA__") + + +def _format_artists(artists_field: object) -> str: + """Beatport ritorna artists come lista di dict {name, ...}. + Formatta come 'A, B & C' (& prima dell'ultimo).""" + if not isinstance(artists_field, list) or not artists_field: + return "" + names = [a.get("name", "") for a in artists_field if isinstance(a, dict)] + names = [n for n in names if n] + if not names: + return "" + if len(names) == 1: + return names[0] + return ", ".join(names[:-1]) + " & " + names[-1] + + +def _parse_tracks(data: dict) -> list: + """Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione.""" + raw = _find_tracks_results(data) + out: list = [] + for i, item in enumerate(raw, 1): + try: + length_ms = int(item.get("length_ms") or 0) + track = BeatportTrack( + position=i, + title=str(item.get("name") or "").strip(), + mix=str(item.get("mix_name") or "").strip(), + artists=_format_artists(item.get("artists")), + duration_sec=length_ms // 1000, + beatport_id=int(item.get("id") or 0), + ) + except (TypeError, ValueError) as e: + raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e + out.append(track) + return out diff --git a/tests/test_beatport.py b/tests/test_beatport.py index 1e533dd..4f3400c 100644 --- a/tests/test_beatport.py +++ b/tests/test_beatport.py @@ -83,3 +83,31 @@ class TestExtractNextData: broken = '' with pytest.raises(beatport.BeatportParseError, match="JSON malformato"): beatport._extract_next_data(broken) + + +class TestParseTracks: + def test_extracts_100_tracks(self, fixtures_dir): + html = (fixtures_dir / "beatport_melodic_top100.html").read_text() + data = beatport._extract_next_data(html) + tracks = beatport._parse_tracks(data) + assert len(tracks) == 100 + + def test_positions_are_sequential_1_to_100(self, fixtures_dir): + html = (fixtures_dir / "beatport_melodic_top100.html").read_text() + tracks = beatport._parse_tracks(beatport._extract_next_data(html)) + positions = [t.position for t in tracks] + assert positions == list(range(1, 101)) + + def test_track_shape(self, fixtures_dir): + html = (fixtures_dir / "beatport_melodic_top100.html").read_text() + tracks = beatport._parse_tracks(beatport._extract_next_data(html)) + first = tracks[0] + assert isinstance(first, beatport.BeatportTrack) + assert first.title + assert first.artists + assert first.duration_sec > 0 + assert first.beatport_id > 0 + + def test_schema_missing_results_raises(self): + with pytest.raises(beatport.BeatportParseError, match="results"): + beatport._parse_tracks({"props": {"pageProps": {}}}) From 3f31e240be4388e026faf3fd19157cfabd98a396 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:24:43 +0200 Subject: [PATCH 07/15] beatport: fetch_top100 con curl_cffi + retry + cache 15min --- core/beatport.py | 65 +++++++++++++++++++++++++++++++++++ tests/test_beatport.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/core/beatport.py b/core/beatport.py index a4d5ff2..1d4b8e9 100644 --- a/core/beatport.py +++ b/core/beatport.py @@ -9,8 +9,11 @@ from __future__ import annotations import json import re +import time from dataclasses import dataclass +from curl_cffi import requests as _cffi_requests + @dataclass(frozen=True) class BeatportTrack: @@ -166,3 +169,65 @@ def _parse_tracks(data: dict) -> list: raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e out.append(track) return out + + +_IMPERSONATE = "chrome131" # aggiorna se CF rompe il fingerprint +_REQUEST_TIMEOUT = 15 +_MAX_ATTEMPTS = 3 +_BACKOFF_SEC = [1, 3] # attese fra tentativi +_CACHE_TTL_SEC = 15 * 60 + +# Cache in-memory: slug → (timestamp_epoch, list[BeatportTrack]) +_cache: dict = {} + + +def _url_for(slug: str) -> str: + gid, _ = GENRES[slug] + return f"https://www.beatport.com/genre/{slug}/{gid}/top-100" + + +def _do_get(url: str) -> str: + """GET con retry e backoff. Solleva BeatportUnreachableError su fallimento definitivo.""" + last_exc = None + for attempt in range(_MAX_ATTEMPTS): + try: + resp = _cffi_requests.get( + url, + impersonate=_IMPERSONATE, + timeout=_REQUEST_TIMEOUT, + ) + 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 BeatportUnreachableError(f"Beatport irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}") + + +def fetch_top100(slug: str, force_refresh: bool = False) -> list: + """Fetches la Top 100 Beatport per il genere dato. + + Cache in-memory 15 min. `force_refresh=True` bypassa la cache. + + Raises: + ValueError: se slug non è in GENRES. + BeatportUnreachableError: rete/5xx dopo i retry. + BeatportParseError: HTML/JSON non conforme allo schema. + """ + 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] + + html = _do_get(_url_for(slug)) + data = _extract_next_data(html) + tracks = _parse_tracks(data) + _cache[slug] = (now, tracks) + return tracks diff --git a/tests/test_beatport.py b/tests/test_beatport.py index 4f3400c..4ece0cf 100644 --- a/tests/test_beatport.py +++ b/tests/test_beatport.py @@ -111,3 +111,80 @@ class TestParseTracks: def test_schema_missing_results_raises(self): with pytest.raises(beatport.BeatportParseError, match="results"): beatport._parse_tracks({"props": {"pageProps": {}}}) + + +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 fixture_html(self, fixtures_dir): + return (fixtures_dir / "beatport_melodic_top100.html").read_text() + + def test_success_returns_100_tracks(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + tracks = beatport.fetch_top100("melodic-house-techno") + assert len(tracks) == 100 + + def test_invalid_slug_raises_value_error(self): + with pytest.raises(ValueError, match="slug"): + beatport.fetch_top100("not-a-real-genre") + + def test_5xx_retries_and_raises_unreachable(self): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response("", 503) + with patch("core.beatport.time.sleep"): # skip backoff + with pytest.raises(beatport.BeatportUnreachableError): + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 3 # 1 + 2 retry + + def test_cache_hit_within_ttl(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 1 + + def test_cache_expires_after_ttl(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + with freeze_time("2026-01-01 10:00:00") as frozen: + beatport.fetch_top100("melodic-house-techno") + frozen.tick(delta=beatport._CACHE_TTL_SEC + 1) + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 2 + + def test_force_refresh_bypasses_cache(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + beatport.fetch_top100("melodic-house-techno", force_refresh=True) + assert mock_get.call_count == 2 + + def test_uses_chrome_impersonation(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + call_kwargs = mock_get.call_args.kwargs + assert "impersonate" in call_kwargs + assert call_kwargs["impersonate"].startswith("chrome") From cc5716e87dc3981baab9abdff8afa202eac48d47 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:26:25 +0200 Subject: [PATCH 08/15] spotify: aggiunto search_track() per feature Beatport --- core/spotify_client.py | 25 +++++++++++++ tests/test_spotify_client.py | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/test_spotify_client.py diff --git a/core/spotify_client.py b/core/spotify_client.py index 2085e55..77570ed 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -255,3 +255,28 @@ def resolve_spotify_track_no_auth(url: str) -> tuple[str, str, list[dict]]: track = {"name": name, "artist": artist} return "Brano", name, [track] + + +def search_track(token: str, query: str): + """Cerca un brano su Spotify e ritorna il primo match. + + Ritorna un dict con {id, url, name, artists} oppure None se nessun match. + Solleva requests.HTTPError su errori server / auth. + """ + resp = requests.get( + "https://api.spotify.com/v1/search", + headers={"Authorization": f"Bearer {token}"}, + params={"q": query, "type": "track", "limit": 1}, + timeout=15, + ) + resp.raise_for_status() + items = resp.json().get("tracks", {}).get("items", []) + if not items: + return None + t = items[0] + return { + "id": t.get("id"), + "url": t.get("external_urls", {}).get("spotify", ""), + "name": t.get("name", ""), + "artists": ", ".join(a.get("name", "") for a in t.get("artists", [])), + } diff --git a/tests/test_spotify_client.py b/tests/test_spotify_client.py new file mode 100644 index 0000000..0590c3e --- /dev/null +++ b/tests/test_spotify_client.py @@ -0,0 +1,70 @@ +"""Test per core.spotify_client.search_track().""" + +from __future__ import annotations + +import pytest +import responses + +from core import spotify_client + + +class TestSearchTrack: + @responses.activate + def test_returns_track_dict_when_found(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={ + "tracks": { + "items": [ + { + "id": "abc123", + "name": "Hot Sauce", + "artists": [{"name": "Kapuchon"}], + "external_urls": {"spotify": "https://open.spotify.com/track/abc123"}, + } + ] + } + }, + status=200, + ) + result = spotify_client.search_track("fake-token", "Kapuchon Hot Sauce") + assert result is not None + assert result["id"] == "abc123" + assert result["url"] == "https://open.spotify.com/track/abc123" + + @responses.activate + def test_returns_none_when_no_results(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"tracks": {"items": []}}, + status=200, + ) + result = spotify_client.search_track("fake-token", "no-match") + assert result is None + + @responses.activate + def test_sends_bearer_token(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"tracks": {"items": []}}, + status=200, + ) + spotify_client.search_track("my-token", "q") + assert responses.calls[0].request.headers["Authorization"] == "Bearer my-token" + + @responses.activate + def test_query_params_correct(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"tracks": {"items": []}}, + status=200, + ) + spotify_client.search_track("t", "Kapuchon Hot Sauce") + params = responses.calls[0].request.params + assert params["q"] == "Kapuchon Hot Sauce" + assert params["type"] == "track" + assert params["limit"] == "1" From 1c52d529868423eb78e787c002f01d9887a7c102 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:27:22 +0200 Subject: [PATCH 09/15] config: v1.8.0 + beatport_last_genre nei DEFAULTS --- core/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/config.py b/core/config.py index 98ce689..5dddc45 100644 --- a/core/config.py +++ b/core/config.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -VERSION = "v1.7.15" +VERSION = "v1.8.0" APP_NAME = "MusicTools" @@ -70,6 +70,8 @@ DEFAULTS = { "cookies_path": str(_project_dir / "cookies.txt"), "output_dir": str(_project_dir / "MUSICA"), "theme": "dark", + # ---- Beatport ---- + "beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato # ---- Licenza ---- "license_key": "", # chiave fornita all'utente via email "license_email": "", # email associata all'acquisto From be4f7e10241512517948b8742a3afd98187daabd Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:30:49 +0200 Subject: [PATCH 10/15] bridge: 4 metodi Api per feature Beatport (fetch + download riusa start_tracks_download) --- api/bridge.py | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/api/bridge.py b/api/bridge.py index 27f0631..018e75c 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -8,9 +8,12 @@ import re import threading import time import webbrowser +from dataclasses import asdict +from pathlib import Path from typing import Any, Optional from core.config import load_config, save_config, VERSION, LICENSE_API_URL +from core import beatport from core import license as license_mod from core.downloader import ( download_playlist, @@ -903,3 +906,91 @@ class Api: }) self._emit("video:done", {"ok": True}) + + # ================================================================ + # Beatport charts + # ================================================================ + def beatport_genres(self) -> list: + """Lista dei generi disponibili per il dropdown UI.""" + return beatport.list_genres() + + def beatport_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict: + """Fetches la Top 100 per il genere. Salva anche l'ultimo genere in config. + Ritorna {ok: True, tracks: [...]} oppure {ok: False, error, message}.""" + # Memoria: ricorda l'ultimo genere usato + try: + cfg = load_config() + cfg["beatport_last_genre"] = slug + save_config(cfg) + except Exception: + pass + + try: + tracks = beatport.fetch_top100(slug, force_refresh=force_refresh) + except ValueError as e: + return {"ok": False, "error": "invalid_genre", "message": str(e)} + except beatport.BeatportUnreachableError as e: + return {"ok": False, "error": "unreachable", "message": str(e)} + except beatport.BeatportParseError as e: + return {"ok": False, "error": "parse", "message": str(e)} + + return {"ok": True, "tracks": [asdict(t) for t in tracks]} + + def beatport_check_existing(self, tracks: list, genre_name: str) -> list: + """Per ogni track ritorna True se il file esiste gia' in output_dir/Beatport//. + Match euristico: nel filename (senza extension) devono comparire 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) + + safe_name = genre_name.replace("/", "_").replace("\\", "_").strip() + out_dir = Path(out_root) / "Beatport" / safe_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 + found = any( + (title in stem and first_artist in stem) + for stem in existing_stems + ) + result.append(found) + return result + + def beatport_download_selected(self, tracks: list, genre_name: str) -> dict: + """Converte i BeatportTrack in tracklist compatibile con start_tracks_download + e lancia il download riusando l'infrastruttura esistente (license gate, + skip file gia' scaricati, log su canale 'download', ecc.).""" + cfg = load_config() + out_root = (cfg.get("output_dir") or "").strip() + if not out_root: + return {"ok": False, "error": "Cartella output non impostata"} + + safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip() + subfolder = f"Beatport/{safe_genre}" if safe_genre else "Beatport" + + converted = [] + 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}) + + if not converted: + return {"ok": False, "error": "Nessun brano valido"} + + return self.start_tracks_download({ + "tracks": converted, + "output_dir": out_root, + "subfolder": subfolder, + }) From 33c6bc77031867bb9c2be3fc9e5e766dcbb434d7 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:37:44 +0200 Subject: [PATCH 11/15] ui: markup tab Beatport --- webui/index.html | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/webui/index.html b/webui/index.html index 0da39e5..a54f68c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -29,6 +29,10 @@ 🎬 Video + + Shift+click per bypassare la cache + +
+
+ + +
+
+ + + + + + + + + + + + + +
+ +
+ + +
+
+
+ + From d2f3ebe361bcda1b3fb0e4e44d703eea93b289af Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:39:04 +0200 Subject: [PATCH 12/15] ui: JS BeatportUI (init, loadChart, download, log) --- webui/js/app.js | 293 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/webui/js/app.js b/webui/js/app.js index 32c3810..7443ddd 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -501,6 +501,9 @@ async function init() { $("#recPathDisplay").classList.remove("empty"); } await refreshRecDevices(); + + // Beatport tab — popola generi, ripristina ultimo genere, aggancia handler + await BeatportUI.init(); } async function refreshRecDevices() { @@ -1170,6 +1173,296 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => { try { await window.pywebview.api.open_purchase_page(); } catch (_e) {} }); +// ============================================================ +// BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica +// (il download riusa start_tracks_download, quindi passa dagli stessi +// canali "download:progress" / "download:done" / log view="download") +// ============================================================ +const BeatportUI = (function () { + let genres = []; + let currentTracks = []; + let existing = []; + let currentGenreName = ""; + let currentGenreSlug = ""; + let downloading = false; + + function fmtDur(sec) { + const n = Number(sec) || 0; + const m = Math.floor(n / 60); + const s = Math.floor(n % 60); + return `${m}:${String(s).padStart(2, "0")}`; + } + + function safeGenreFolder(name) { + return String(name || "").replace(/[\/\\]/g, "_").trim(); + } + + function populateSelect() { + const sel = $("#beatport-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 = $("#beatport-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}/Beatport/${folder}` + : `Destinazione: ${root}/Beatport`; + info.className = "beatport-output-info"; + } + + function setStatus(text, kind) { + const el = $("#beatport-status"); + if (!el) return; + el.textContent = text || ""; + el.className = "beatport-status" + (kind ? " " + kind : ""); + } + + async function loadChart(forceRefresh) { + const sel = $("#beatport-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"); + $("#beatport-table").hidden = true; + $("#beatport-toolbar").hidden = true; + $("#beatport-table").querySelector("tbody").innerHTML = ""; + + let res; + try { + res = await window.pywebview.api.beatport_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 = "Beatport 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.beatport_check_existing(currentTracks, currentGenreName); + } catch (_e) { + existing = currentTracks.map(() => false); + } + setStatus(`${currentTracks.length} brani caricati`, "ok"); + renderTable(); + } + + function renderTable() { + const table = $("#beatport-table"); + const tbody = table.querySelector("tbody"); + tbody.innerHTML = ""; + + currentTracks.forEach((t, i) => { + const tr = document.createElement("tr"); + if (existing[i]) 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.addEventListener("change", updateSelectionCount); + tdCheck.appendChild(cb); + tr.appendChild(tdCheck); + + const tdPos = document.createElement("td"); + tdPos.className = "col-pos"; + tdPos.textContent = String(t.position || (i + 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 tdDur = document.createElement("td"); + tdDur.className = "col-dur"; + tdDur.textContent = fmtDur(t.duration_sec); + tr.appendChild(tdDur); + + const tdState = document.createElement("td"); + tdState.className = "col-state"; + if (existing[i]) tdState.textContent = "✓ già scaricato"; + tr.appendChild(tdState); + + tbody.appendChild(tr); + }); + + table.hidden = false; + $("#beatport-toolbar").hidden = false; + updateOutputInfo(); + updateSelectionCount(); + } + + function updateSelectionCount() { + const boxes = $$("#beatport-table tbody input[type=checkbox]"); + const total = boxes.length; + const checked = boxes.filter((b) => b.checked).length; + $("#beatport-selected-count").textContent = `${checked}/${total} selezionati`; + const dlBtn = $("#beatport-download-btn"); + if (dlBtn) dlBtn.disabled = downloading || checked === 0; + + const master = $("#beatport-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) { + $$("#beatport-table tbody input[type=checkbox]").forEach((b) => { b.checked = checked; }); + updateSelectionCount(); + } + + function appendBeatportLog(msg) { + const el = $("#beatport-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 = $$("#beatport-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; + $("#beatport-download-btn").disabled = true; + $("#beatport-stop-btn").hidden = false; + $("#beatport-stop-btn").disabled = false; + $("#beatport-log").innerHTML = ""; + appendBeatportLog(`[INFO] Avvio download di ${selected.length} brani da ${currentGenreName}…`); + + let res; + try { + res = await window.pywebview.api.beatport_download_selected(selected, currentGenreName); + } catch (e) { + appendBeatportLog("[ERRORE] " + ((e && e.message) || e)); + finishDownload(); + return; + } + if (!res || !res.ok) { + const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download"; + appendBeatportLog("[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; + $("#beatport-stop-btn").hidden = true; + $("#beatport-stop-btn").disabled = true; + updateSelectionCount(); + // Re-check existing per aggiornare i badge "già scaricato" + if (currentTracks.length) { + window.pywebview.api.beatport_check_existing(currentTracks, currentGenreName) + .then((res) => { existing = res || existing; renderTable(); }) + .catch(() => {}); + } + } + + async function init() { + // Popola dropdown generi + try { + genres = await window.pywebview.api.beatport_genres(); + } catch (e) { + console.error("Beatport: impossibile caricare i generi", e); + genres = []; + } + populateSelect(); + + // Ripristina l'ultimo genere selezionato (state.config gia' caricato) + const last = (state.config && state.config.beatport_last_genre) || ""; + const sel = $("#beatport-genre"); + if (sel && last && genres.some((g) => g.slug === last)) { + sel.value = last; + } + updateOutputInfo(); + + // Bind eventi + $("#beatport-load-btn").addEventListener("click", (e) => loadChart(e.shiftKey)); + $("#beatport-download-btn").addEventListener("click", startDownload); + $("#beatport-stop-btn").addEventListener("click", stopDownload); + $("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked)); + + // Aggancia agli event listener esistenti del canale "download" senza rompere + // il comportamento della tab Scarica (wrapping additivo). + const origProgress = bridgeHandlers["download:progress"]; + bridgeHandlers["download:progress"] = (p) => { + if (origProgress) origProgress(p); + // I contatori del tab Scarica sono aggiornati dall'handler originale; + // qui potremmo aggiungere un contatore Beatport, ma i log sono sufficienti. + }; + 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") appendBeatportLog(msg); + }; + } + + return { init }; +})(); + // ============================================================ // Boot // ============================================================ From faf74dfb113abc09f55cd85d7f401d66e0476185 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:39:36 +0200 Subject: [PATCH 13/15] ui: stili tab Beatport --- webui/css/style.css | 128 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/webui/css/style.css b/webui/css/style.css index aa26bbe..9c90002 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -1550,3 +1550,131 @@ input[type="number"]::-webkit-inner-spin-button { white-space: pre-wrap; word-break: break-word; } + +/* =============================== + BEATPORT + =============================== */ +.beatport-header { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.beatport-header select.input { + min-width: 260px; + flex: 0 1 320px; +} + +.hint-inline { + color: var(--text-3); + font-size: 12px; + margin-left: auto; +} + +.beatport-status { + margin-top: 12px; + font-size: 13px; + color: var(--text-2); + min-height: 18px; +} + +.beatport-status.loading { color: var(--text-3); font-style: italic; } +.beatport-status.error { color: var(--red); font-weight: 600; } +.beatport-status.ok { color: var(--green); font-weight: 600; } + +.beatport-output-info { + margin-top: 6px; + font-size: 12px; + color: var(--text-3); + font-family: "SF Mono", Menlo, Consolas, monospace; + word-break: break-all; +} + +.beatport-output-info.warn { + color: var(--amber-hover); + font-family: inherit; +} + +.beatport-table-card { + padding: 0; + overflow: hidden; +} + +.beatport-table-wrap { + max-height: 460px; + overflow: auto; +} + +.beatport-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.beatport-table thead th { + position: sticky; + top: 0; + background: var(--bg-card-2); + color: var(--text-3); + font-weight: 700; + font-size: 11px; + letter-spacing: 0.6px; + text-transform: uppercase; + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + z-index: 1; +} + +.beatport-table tbody td { + padding: 8px 12px; + border-bottom: 1px solid var(--divider); + color: var(--text); + vertical-align: middle; +} + +.beatport-table tbody tr:hover { + background: rgba(99, 102, 241, 0.06); +} + +.beatport-table tbody tr.already-downloaded td { + color: var(--text-3); +} + +.beatport-table tbody tr.already-downloaded .col-state { + color: var(--green); + font-weight: 600; +} + +.beatport-table .col-check { width: 36px; } +.beatport-table .col-pos { width: 40px; color: var(--text-3); font-variant-numeric: tabular-nums; } +.beatport-table .col-dur { width: 70px; color: var(--text-2); font-variant-numeric: tabular-nums; text-align: right; } +.beatport-table .col-state { width: 130px; font-size: 12px; } + +.beatport-table input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--indigo, #6366F1); + cursor: pointer; +} + +.beatport-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + border-top: 1px solid var(--divider); + background: var(--bg-card); +} + +.beatport-toolbar .counter { + margin-left: 0; + margin-right: auto; +} + +@media (max-width: 720px) { + .beatport-header { flex-direction: column; align-items: stretch; } + .hint-inline { margin-left: 0; } + .beatport-table .col-state { width: 110px; } +} From 6bff24ff7d95b62be1693a9980f19d3b7cd20355 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:41:38 +0200 Subject: [PATCH 14/15] beatport: fix check_existing/download path mismatch start_tracks_download sanitizza gli slash del subfolder (/ -> _), quindi i file finivano in Beatport_/ ma check_existing cercava in Beatport//. Estratto helper _beatport_output_dir per allinearli. --- api/bridge.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/api/bridge.py b/api/bridge.py index 018e75c..fd47c6b 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -936,17 +936,26 @@ class Api: return {"ok": True, "tracks": [asdict(t) for t in tracks]} + def _beatport_output_dir(self, out_root: str, genre_name: str) -> Path: + """Calcola la cartella dove finiscono i file Beatport. + Coerente col comportamento di `start_tracks_download`, che sanitizza + gli slash nel subfolder (`/` -> `_`), quindi `Beatport/` + diventa flat: `Beatport_`.""" + safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip() + subfolder = f"Beatport_{safe_genre}" if safe_genre else "Beatport" + return Path(out_root) / subfolder + def beatport_check_existing(self, tracks: list, genre_name: str) -> list: - """Per ogni track ritorna True se il file esiste gia' in output_dir/Beatport//. - Match euristico: nel filename (senza extension) devono comparire sia il titolo - che il primo artista (case-insensitive).""" + """Per ogni track ritorna True se il file esiste gia' nella cartella + di output Beatport (stessa dove `beatport_download_selected` scrive). + Match euristico: nel filename (senza extension) devono comparire 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) - safe_name = genre_name.replace("/", "_").replace("\\", "_").strip() - out_dir = Path(out_root) / "Beatport" / safe_name + out_dir = self._beatport_output_dir(out_root, genre_name) if not out_dir.exists(): return [False] * len(tracks) @@ -975,8 +984,11 @@ class Api: if not out_root: return {"ok": False, "error": "Cartella output non impostata"} - safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip() - subfolder = f"Beatport/{safe_genre}" if safe_genre else "Beatport" + # Sfrutta lo stesso computed path del check_existing per garantire + # che i due metodi restino allineati (senza affidarsi a start_tracks_download + # per la sanitizzazione). + target_dir = self._beatport_output_dir(out_root, genre_name) + subfolder = target_dir.name # es. "Beatport_melodic-house-techno" converted = [] for t in tracks: From f850e86a889b4fba971f51ee886289e4c232fd93 Mon Sep 17 00:00:00 2001 From: luciano Date: Tue, 14 Jul 2026 16:42:27 +0200 Subject: [PATCH 15/15] docs: implementation plan Beatport charts --- .../plans/2026-07-14-beatport-charts.md | 1752 +++++++++++++++++ 1 file changed, 1752 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-beatport-charts.md diff --git a/docs/superpowers/plans/2026-07-14-beatport-charts.md b/docs/superpowers/plans/2026-07-14-beatport-charts.md new file mode 100644 index 0000000..174e289 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-beatport-charts.md @@ -0,0 +1,1752 @@ +# Beatport Charts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Aggiungi una tab "🎧 Beatport" a MusicTools che carica le Top 100 per genere dal sito Beatport, permette selezione via checkbox e scarica i brani riusando il downloader esistente (Spotify search → yt-dlp → .mp3). + +**Architecture:** Nuovo modulo `core/beatport.py` (parser stateless con cache in-memory), 4 nuovi metodi in `main.py` (bridge JS↔Python via pywebview), nuova sezione UI in `webui/`. Parsing dati Beatport via estrazione JSON `__NEXT_DATA__` embeddato in ogni pagina Next.js. Zero cambi lato server, zero migration. + +**Tech Stack:** Python 3.11+, `requests`, `re`, `json`, `dataclasses`, `pathlib` (già in requirements). `pytest` + `responses` come nuove dev-deps. Frontend: vanilla JS + HTML/CSS (nessun framework, coerente col resto dell'app). + +**Spec di riferimento:** `docs/superpowers/specs/2026-07-14-beatport-charts-design.md` + +--- + +## Note operative + +- **Author email git:** usa `git -c user.email=info@djluza.com commit …` per ogni commit di questo piano (non modificare il config globale) +- **Branch:** lavora su `feat/beatport-charts` per isolare. `git checkout -b feat/beatport-charts` prima della Task 0 +- **Framework di test:** questo repo non ha ancora `tests/`. Le Task 0 e 1 lo impostano +- **Non modificare** `server/`, `landing/`, `build_macos.py`, `build_windows.py` — questa feature è additive lato client +- **⚠ Cloudflare:** Beatport è dietro Cloudflare Managed Challenge — `requests` puro riceve 403. La feature usa **`curl_cffi`** (TLS fingerprint impersonation di Chrome) che bypassa CF. Verificato funzionante il 2026-07-14 con `impersonate="chrome131"` → HTTP 200, ~885KB, `__NEXT_DATA__` presente. Task 1 e 6 sono aggiornate di conseguenza. + +--- + +## Task 0: Setup branch, tests dir, dev dependencies + +**Files:** +- Create: `tests/__init__.py` (vuoto) +- Create: `tests/conftest.py` +- Modify: `requirements.txt` + +- [ ] **Step 1: Crea branch** + +```bash +cd /Users/luciano/Downloads/Progetti2026/MusicDownload +git checkout -b feat/beatport-charts +``` + +- [ ] **Step 2: Aggiungi test deps a requirements.txt** + +Aggiungi in fondo al file: + +``` +# --- dev only --- +pytest>=8.0.0 +responses>=0.25.0 +freezegun>=1.4.0 +``` + +- [ ] **Step 3: Installa deps** + +Run: `python3 -m pip install -r requirements.txt` +Expected: pytest, responses, freezegun installati senza errori + +- [ ] **Step 4: Crea tests/__init__.py vuoto** + +```bash +mkdir -p tests/fixtures +touch tests/__init__.py +``` + +- [ ] **Step 5: Crea tests/conftest.py** + +```python +"""Fixtures pytest condivise.""" +from pathlib import Path + +import pytest + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def fixtures_dir() -> Path: + return FIXTURES_DIR +``` + +- [ ] **Step 6: Verifica pytest gira (0 test)** + +Run: `python3 -m pytest tests/ -v` +Expected: `no tests ran in 0.XXs`, exit code 5 (nessun test) — è ok + +- [ ] **Step 7: Commit** + +```bash +git add tests/__init__.py tests/conftest.py requirements.txt +git -c user.email=info@djluza.com commit -m "test: setup pytest infra per feature Beatport" +``` + +--- + +## Task 1: HTML fixture reale + genres list script + +**Files:** +- Create: `scripts/refresh_beatport_genres.py` +- Create: `tests/fixtures/beatport_melodic_top100.html` +- Modify: `requirements.txt` (aggiungi `curl_cffi>=0.9.0` come runtime dep) + +- [ ] **Step 0: Aggiungi `curl_cffi` a requirements.txt come runtime dep** + +Beatport è dietro Cloudflare Managed Challenge — `requests` puro riceve 403. Serve `curl_cffi` che impersona il TLS fingerprint di Chrome. + +Modifica `requirements.txt` — aggiungi prima del blocco `# --- dev only ---`: + +``` +curl_cffi>=0.9.0 +``` + +Installa: `python3 -m pip install -r requirements.txt` + +- [ ] **Step 1: Scarica una pagina Beatport reale come fixture (via curl_cffi)** + +```bash +python3 -c " +from curl_cffi import requests +r = requests.get( + 'https://www.beatport.com/genre/melodic-house-techno/90/top-100', + impersonate='chrome131', + timeout=15, +) +assert r.status_code == 200, f'HTTP {r.status_code}' +with open('tests/fixtures/beatport_melodic_top100.html', 'w') as f: + f.write(r.text) +print('saved', len(r.text), 'bytes') +" +``` + +Verifica: `ls -la tests/fixtures/beatport_melodic_top100.html` → file > 500KB (atteso ~885KB). +Se è < 100KB o lo script fallisce: **STOP e chiedi supporto** (potrebbe essere cambiata la protezione CF). + +- [ ] **Step 2: Verifica che `__NEXT_DATA__` sia presente** + +Run: `grep -c '__NEXT_DATA__' tests/fixtures/beatport_melodic_top100.html` +Expected: `1` (esattamente un match) + +Se `0`: pagina non è Next.js-served (Beatport ha cambiato tech). Interrompi e chiedi conferma design. + +- [ ] **Step 3: Ispeziona lo schema JSON (una tantum, non automatizzato)** + +```bash +python3 -c " +import re, json +html = open('tests/fixtures/beatport_melodic_top100.html').read() +m = re.search(r'', html, re.DOTALL) +data = json.loads(m.group(1)) +# Naviga: props > pageProps > dehydratedState > queries > [i] > state > data > results +queries = data['props']['pageProps']['dehydratedState']['queries'] +for i, q in enumerate(queries): + d = q.get('state', {}).get('data', {}) + if isinstance(d, dict) and 'results' in d: + results = d['results'] + if isinstance(results, list) and len(results) >= 50: + print(f'queries[{i}]: {len(results)} results') + print(' sample keys:', list(results[0].keys())[:15]) +" +``` + +Aspettato output tipo: +``` +queries[N]: 100 results + sample keys: ['id', 'name', 'mix_name', 'artists', 'length_ms', 'chart_position', ...] +``` + +Se le chiavi non sono `id`, `name`, `mix_name`, `artists`, `length_ms` (o simili): adatta i selettori nelle Task 4-5 di conseguenza — annotali qui prima di proseguire. + +- [ ] **Step 4: Crea script per enumerare i generi (opzionale, run manuale)** + +```python +# scripts/refresh_beatport_genres.py +"""Estrae lo slug + numeric id di tutti i generi Beatport dalla pagina indice. +Uso: python3 scripts/refresh_beatport_genres.py > /tmp/genres.txt +Poi copia manualmente in core/beatport.py::GENRES.""" + +from __future__ import annotations + +import re +import json +import sys +from curl_cffi import requests + + +def main() -> int: + resp = requests.get( + "https://www.beatport.com/genres", + impersonate="chrome131", + timeout=15, + ) + resp.raise_for_status() + m = re.search(r'', resp.text, re.DOTALL) + if not m: + print("__NEXT_DATA__ non trovato", file=sys.stderr) + return 1 + data = json.loads(m.group(1)) + queries = data["props"]["pageProps"]["dehydratedState"]["queries"] + for q in queries: + d = q.get("state", {}).get("data", {}) + if isinstance(d, dict) and "results" in d and isinstance(d["results"], list): + for g in d["results"]: + if "slug" in g and "id" in g and "name" in g: + print(f' "{g["slug"]}": ({g["id"]}, "{g["name"]}"),') + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 5: Genera la mappa completa dei generi** + +```bash +mkdir -p scripts +# crea il file dal blocco sopra +python3 scripts/refresh_beatport_genres.py > /tmp/genres_output.txt +head -5 /tmp/genres_output.txt +wc -l /tmp/genres_output.txt +``` + +Expected: ~30 righe nel formato ` "slug": (id, "Name"),`. Salva questo output — servirà nella Task 2. + +Se lo script fallisce (bot detection): usa manualmente per ora la lista parziale nel Task 2 e apri issue separato per completarla. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/refresh_beatport_genres.py tests/fixtures/beatport_melodic_top100.html +git -c user.email=info@djluza.com commit -m "beatport: fixture HTML per test + script refresh generi" +``` + +--- + +## Task 2: GENRES map + list_genres() — TDD + +**Files:** +- Create: `core/beatport.py` +- Create: `tests/test_beatport.py` + +- [ ] **Step 1: Scrivi test per list_genres()** + +Crea `tests/test_beatport.py`: + +```python +"""Test per core.beatport.""" + +import pytest + +from core import beatport + + +class TestListGenres: + def test_returns_list_of_dicts(self): + result = beatport.list_genres() + assert isinstance(result, list) + assert len(result) >= 10 # almeno 10 generi + 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 = beatport.list_genres() + names = [g["name"] for g in result] + assert names == sorted(names, key=str.casefold) + + def test_melodic_house_techno_present(self): + result = beatport.list_genres() + slugs = [g["slug"] for g in result] + assert "melodic-house-techno" in slugs +``` + +- [ ] **Step 2: Verifica che i test falliscano** + +Run: `python3 -m pytest tests/test_beatport.py -v` +Expected: FAIL con `ModuleNotFoundError: No module named 'core.beatport'` + +- [ ] **Step 3: Implementa core/beatport.py minimo** + +```python +"""Fetch Top 100 Beatport per genere. + +Approccio: estrai il JSON `__NEXT_DATA__` dal HTML della pagina Next.js. +Vedi docs/superpowers/specs/2026-07-14-beatport-charts-design.md. +""" + +from __future__ import annotations + + +# Mappa slug URL Beatport → (numeric_id, display_name) +# Enumerata via scripts/refresh_beatport_genres.py (vedi Task 1). +# Sostituisci il blocco qui sotto con l'output completo di quello script. +GENRES: dict[str, tuple[int, str]] = { + "afro-house": (89, "Afro House"), + "deep-house": (12, "Deep House"), + "drum-bass": (1, "Drum & Bass"), + "dubstep": (18, "Dubstep"), + "house": (5, "House"), + "indie-dance": (37, "Indie Dance"), + "melodic-house-techno": (90, "Melodic House & Techno"), + "minimal-deep-tech": (14, "Minimal / Deep Tech"), + "organic-house-downtempo": (93, "Organic House / Downtempo"), + "progressive-house": (15, "Progressive House"), + "tech-house": (11, "Tech House"), + "techno-peak-time-driving": (6, "Techno (Peak Time / Driving)"), + "techno-raw-deep-hypnotic": (92, "Techno (Raw / Deep / Hypnotic)"), + "trance": (7, "Trance"), +} + + +def list_genres() -> list[dict]: + """Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name.""" + 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 la lista completa dallo script della Task 1:** sostituisci il blocco `GENRES = {…}` con l'output completo. Il test `test_returns_list_of_dicts` accetta ≥10 generi, quindi la lista parziale sopra è già valida. + +- [ ] **Step 4: Verifica test passano** + +Run: `python3 -m pytest tests/test_beatport.py -v` +Expected: 3 test PASS + +- [ ] **Step 5: Commit** + +```bash +git add core/beatport.py tests/test_beatport.py +git -c user.email=info@djluza.com commit -m "beatport: GENRES + list_genres() con test" +``` + +--- + +## Task 3: BeatportTrack dataclass — TDD + +**Files:** +- Modify: `core/beatport.py` +- Modify: `tests/test_beatport.py` + +- [ ] **Step 1: Aggiungi test per BeatportTrack** + +Aggiungi in fondo a `tests/test_beatport.py`: + +```python +class TestBeatportTrack: + def test_display_format(self): + t = beatport.BeatportTrack( + position=1, + title="Hot Sauce", + mix="Extended", + artists="Kapuchon, Miss Monique & GLZ", + duration_sec=336, + beatport_id=12345, + ) + assert t.display == "Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)" + + def test_display_pads_seconds(self): + t = beatport.BeatportTrack( + position=1, title="X", mix="Y", artists="A", + duration_sec=65, beatport_id=1, + ) + assert t.display.endswith("(1:05)") + + def test_spotify_query(self): + t = beatport.BeatportTrack( + position=1, + title="Hot Sauce", + mix="Extended", + artists="Kapuchon, Miss Monique & GLZ", + duration_sec=336, + beatport_id=12345, + ) + assert t.spotify_query == "Kapuchon, Miss Monique & GLZ Hot Sauce" + + def test_is_frozen(self): + t = beatport.BeatportTrack( + position=1, title="X", mix="Y", artists="A", + duration_sec=1, beatport_id=1, + ) + with pytest.raises(Exception): + t.title = "Z" # frozen=True impedisce mutazione +``` + +- [ ] **Step 2: Verifica fail** + +Run: `python3 -m pytest tests/test_beatport.py::TestBeatportTrack -v` +Expected: FAIL con `AttributeError: module 'core.beatport' has no attribute 'BeatportTrack'` + +- [ ] **Step 3: Aggiungi la dataclass a core/beatport.py** + +In cima a `core/beatport.py`, prima di `GENRES`, aggiungi: + +```python +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BeatportTrack: + position: int + title: str + mix: str # es. "Extended Mix", "Original Mix", "" + artists: str # es. "A, B & C" già formattato + duration_sec: int + beatport_id: int + + @property + def display(self) -> str: + """Formato testo compatibile coi file .txt curati a mano: + 'Artista – Titolo (Mix) (M:SS)'.""" + m, s = divmod(self.duration_sec, 60) + return f"{self.artists} – {self.title} ({self.mix}) ({m}:{s:02d})" + + @property + def spotify_query(self) -> str: + """Query pura per la search Spotify (senza mix name, che disturba + il matching su titoli tipo 'Extended Mix').""" + return f"{self.artists} {self.title}" +``` + +- [ ] **Step 4: Verifica test passano** + +Run: `python3 -m pytest tests/test_beatport.py -v` +Expected: 7 test PASS + +- [ ] **Step 5: Commit** + +```bash +git add core/beatport.py tests/test_beatport.py +git -c user.email=info@djluza.com commit -m "beatport: BeatportTrack dataclass con display + spotify_query" +``` + +--- + +## Task 4: Estrazione `__NEXT_DATA__` + eccezioni — TDD + +**Files:** +- Modify: `core/beatport.py` +- Modify: `tests/test_beatport.py` + +- [ ] **Step 1: Aggiungi test per parser interno** + +Aggiungi in fondo a `tests/test_beatport.py`: + +```python +class TestExtractNextData: + def test_extracts_json_from_valid_html(self, fixtures_dir): + html = (fixtures_dir / "beatport_melodic_top100.html").read_text() + data = beatport._extract_next_data(html) + assert isinstance(data, dict) + assert "props" in data + + def test_missing_script_raises(self): + with pytest.raises(beatport.BeatportParseError, match="__NEXT_DATA__ non trovato"): + beatport._extract_next_data("nulla") + + def test_malformed_json_raises(self): + broken = '' + with pytest.raises(beatport.BeatportParseError, match="JSON malformato"): + beatport._extract_next_data(broken) +``` + +- [ ] **Step 2: Verifica fail** + +Run: `python3 -m pytest tests/test_beatport.py::TestExtractNextData -v` +Expected: FAIL + +- [ ] **Step 3: Implementa eccezioni + `_extract_next_data`** + +Aggiungi a `core/beatport.py` (dopo gli import): + +```python +import json +import re + + +class BeatportError(Exception): + """Base per errori Beatport.""" + + +class BeatportUnreachableError(BeatportError): + """Rete o server Beatport non raggiungibile / 5xx.""" + + +class BeatportParseError(BeatportError): + """HTML/JSON ricevuto ma non conforme allo schema atteso.""" + + +_NEXT_DATA_RE = re.compile( + r'', + re.DOTALL, +) + + +def _extract_next_data(html: str) -> dict: + """Estrae il payload JSON dallo script <__NEXT_DATA__> di Next.js.""" + m = _NEXT_DATA_RE.search(html) + if not m: + raise BeatportParseError("__NEXT_DATA__ non trovato nella pagina Beatport") + try: + return json.loads(m.group(1)) + except json.JSONDecodeError as e: + raise BeatportParseError(f"__NEXT_DATA__ JSON malformato: {e}") from e +``` + +- [ ] **Step 4: Verifica test passano** + +Run: `python3 -m pytest tests/test_beatport.py -v` +Expected: 10 test PASS + +- [ ] **Step 5: Commit** + +```bash +git add core/beatport.py tests/test_beatport.py +git -c user.email=info@djluza.com commit -m "beatport: estrazione __NEXT_DATA__ + eccezioni tipizzate" +``` + +--- + +## Task 5: Walk JSON → list[BeatportTrack] — TDD + +**Files:** +- Modify: `core/beatport.py` +- Modify: `tests/test_beatport.py` + +- [ ] **Step 1: Aggiungi test parser tracce** + +⚠️ **Prima di scrivere questo test rileggi l'output della Task 1 Step 3** — le chiavi effettive del JSON Beatport potrebbero differire. Il test qui sotto assume `id`, `name`, `mix_name`, `artists` (list of `{name}`), `length_ms`. Se sono diverse, adatta sia il test che l'implementazione coerentemente. + +Aggiungi in fondo a `tests/test_beatport.py`: + +```python +class TestParseTracks: + def test_extracts_100_tracks(self, fixtures_dir): + html = (fixtures_dir / "beatport_melodic_top100.html").read_text() + data = beatport._extract_next_data(html) + tracks = beatport._parse_tracks(data) + assert len(tracks) == 100 + + def test_positions_are_sequential_1_to_100(self, fixtures_dir): + html = (fixtures_dir / "beatport_melodic_top100.html").read_text() + tracks = beatport._parse_tracks(beatport._extract_next_data(html)) + positions = [t.position for t in tracks] + assert positions == list(range(1, 101)) + + def test_track_shape(self, fixtures_dir): + html = (fixtures_dir / "beatport_melodic_top100.html").read_text() + tracks = beatport._parse_tracks(beatport._extract_next_data(html)) + first = tracks[0] + assert isinstance(first, beatport.BeatportTrack) + assert first.title + assert first.artists + assert first.duration_sec > 0 + assert first.beatport_id > 0 + + def test_schema_missing_results_raises(self): + with pytest.raises(beatport.BeatportParseError, match="results"): + beatport._parse_tracks({"props": {"pageProps": {}}}) +``` + +- [ ] **Step 2: Verifica fail** + +Run: `python3 -m pytest tests/test_beatport.py::TestParseTracks -v` +Expected: FAIL + +- [ ] **Step 3: Implementa `_parse_tracks`** + +Aggiungi a `core/beatport.py`: + +```python +def _find_tracks_results(data: dict) -> list[dict]: + """Cerca dentro le queries dehydrated il primo `results` che ha almeno 50 elementi + e la shape di una track (chiave `id` presente).""" + try: + queries = data["props"]["pageProps"]["dehydratedState"]["queries"] + except (KeyError, TypeError) as e: + raise BeatportParseError(f"schema JSON inatteso (queries mancanti): {e}") from e + + for q in queries: + state_data = (q or {}).get("state", {}).get("data") + if not isinstance(state_data, dict): + continue + results = state_data.get("results") + if isinstance(results, list) and len(results) >= 50: + if results and isinstance(results[0], dict) and "id" in results[0]: + return results + raise BeatportParseError("nessun `results` di 50+ track trovato in __NEXT_DATA__") + + +def _format_artists(artists_field: object) -> str: + """Beatport ritorna artists come lista di dict {name, ...}. + Formatta come 'A, B & C' (& prima dell'ultimo).""" + if not isinstance(artists_field, list) or not artists_field: + return "" + names = [a.get("name", "") for a in artists_field if isinstance(a, dict)] + names = [n for n in names if n] + if not names: + return "" + if len(names) == 1: + return names[0] + return ", ".join(names[:-1]) + " & " + names[-1] + + +def _parse_tracks(data: dict) -> list[BeatportTrack]: + """Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione.""" + raw = _find_tracks_results(data) + out: list[BeatportTrack] = [] + for i, item in enumerate(raw, 1): + try: + length_ms = int(item.get("length_ms") or 0) + track = BeatportTrack( + position=i, + title=str(item.get("name") or "").strip(), + mix=str(item.get("mix_name") or "").strip(), + artists=_format_artists(item.get("artists")), + duration_sec=length_ms // 1000, + beatport_id=int(item.get("id") or 0), + ) + except (TypeError, ValueError) as e: + raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e + out.append(track) + return out +``` + +**Se le chiavi effettive del JSON (visto in Task 1 Step 3) sono diverse:** adatta `item.get("name")`, `item.get("mix_name")`, `item.get("artists")`, `item.get("length_ms")`, `item.get("id")` di conseguenza. + +- [ ] **Step 4: Verifica test passano** + +Run: `python3 -m pytest tests/test_beatport.py -v` +Expected: 14 test PASS + +Se `test_track_shape` fallisce con `duration_sec == 0`: la chiave della durata non è `length_ms` — controlla nell'output Task 1 Step 3 e correggi. + +- [ ] **Step 5: Commit** + +```bash +git add core/beatport.py tests/test_beatport.py +git -c user.email=info@djluza.com commit -m "beatport: parse JSON → list[BeatportTrack]" +``` + +--- + +## Task 6: fetch_top100 con HTTP, retry, cache — TDD + +**Files:** +- Modify: `core/beatport.py` +- Modify: `tests/test_beatport.py` + +- [ ] **Step 1: Aggiungi test per fetch_top100 (mock su curl_cffi)** + +`responses` non funziona con `curl_cffi` — usa `unittest.mock.patch` sul modulo importato. + +Aggiungi in fondo a `tests/test_beatport.py`: + +```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 fixture_html(self, fixtures_dir): + return (fixtures_dir / "beatport_melodic_top100.html").read_text() + + def test_success_returns_100_tracks(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + tracks = beatport.fetch_top100("melodic-house-techno") + assert len(tracks) == 100 + + def test_invalid_slug_raises_value_error(self): + with pytest.raises(ValueError, match="slug"): + beatport.fetch_top100("not-a-real-genre") + + def test_5xx_retries_and_raises_unreachable(self): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response("", 503) + with patch("core.beatport.time.sleep"): # skip backoff nei test + with pytest.raises(beatport.BeatportUnreachableError): + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 3 # 1 + 2 retry + + def test_cache_hit_within_ttl(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 1 # seconda call servita da cache + + def test_cache_expires_after_ttl(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + with freeze_time("2026-01-01 10:00:00") as frozen: + beatport.fetch_top100("melodic-house-techno") + frozen.tick(delta=beatport._CACHE_TTL_SEC + 1) + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 2 + + def test_force_refresh_bypasses_cache(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + beatport.fetch_top100("melodic-house-techno", force_refresh=True) + assert mock_get.call_count == 2 + + def test_uses_chrome_impersonation(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + # verifica che venga passato impersonate="chrome..." + call_kwargs = mock_get.call_args.kwargs + assert "impersonate" in call_kwargs + assert call_kwargs["impersonate"].startswith("chrome") +``` + +- [ ] **Step 2: Verifica fail** + +Run: `python3 -m pytest tests/test_beatport.py::TestFetchTop100 -v` +Expected: FAIL + +- [ ] **Step 3: Implementa fetch_top100 + cache + retry (via curl_cffi)** + +Aggiungi a `core/beatport.py`: + +```python +import time + +# curl_cffi bypassa Cloudflare Managed Challenge tramite TLS impersonation. +# Import aliased così i test possono fare patch("core.beatport._cffi_requests.get"). +from curl_cffi import requests as _cffi_requests + + +_IMPERSONATE = "chrome131" # aggiorna se CF rompe il fingerprint +_REQUEST_TIMEOUT = 15 +_MAX_ATTEMPTS = 3 +_BACKOFF_SEC = [1, 3] # attese fra tentativo 1→2 e 2→3 +_CACHE_TTL_SEC = 15 * 60 + +# Cache in-memory: slug → (timestamp_epoch, list[BeatportTrack]) +_cache: dict[str, tuple[float, list[BeatportTrack]]] = {} + + +def _url_for(slug: str) -> str: + gid, _ = GENRES[slug] + return f"https://www.beatport.com/genre/{slug}/{gid}/top-100" + + +def _do_get(url: str) -> str: + """GET con retry e backoff. Solleva BeatportUnreachableError su fallimento definitivo.""" + last_exc: Exception | None = None + for attempt in range(_MAX_ATTEMPTS): + try: + resp = _cffi_requests.get( + url, + impersonate=_IMPERSONATE, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code >= 500 or resp.status_code == 403: + # 403 = Cloudflare challenge non superata → retry + 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 BeatportUnreachableError(f"Beatport irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}") + + +def fetch_top100(slug: str, force_refresh: bool = False) -> list[BeatportTrack]: + """Fetches la Top 100 Beatport per il genere dato. + + Cache in-memory 15 min. `force_refresh=True` bypassa la cache. + + Raises: + ValueError: se slug non è in GENRES. + BeatportUnreachableError: rete/5xx dopo i retry. + BeatportParseError: HTML/JSON non conforme allo schema. + """ + 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] + + html = _do_get(_url_for(slug)) + data = _extract_next_data(html) + tracks = _parse_tracks(data) + _cache[slug] = (now, tracks) + return tracks +``` + +- [ ] **Step 4: Verifica test passano** + +Run: `python3 -m pytest tests/test_beatport.py -v` +Expected: 21 test PASS + +Nota: il test `test_5xx_retries_and_raises_unreachable` fa 3 richieste con backoff 1s+3s → dura ~4s. Ok. + +- [ ] **Step 5: Commit** + +```bash +git add core/beatport.py tests/test_beatport.py +git -c user.email=info@djluza.com commit -m "beatport: fetch_top100 con HTTP + retry + cache 15min" +``` + +--- + +## Task 7: Aggiungi search_track() a spotify_client.py — TDD + +**Files:** +- Modify: `core/spotify_client.py` +- Create: `tests/test_spotify_client.py` + +Il modulo `core/spotify_client.py` esiste ma **non ha** una funzione `search_track()` pubblica. La aggiungiamo. + +- [ ] **Step 1: Scrivi test per search_track** + +Crea `tests/test_spotify_client.py`: + +```python +"""Test per core.spotify_client.search_track().""" + +import pytest +import responses + +from core import spotify_client + + +class TestSearchTrack: + @responses.activate + def test_returns_track_dict_when_found(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={ + "tracks": { + "items": [ + { + "id": "abc123", + "name": "Hot Sauce", + "artists": [{"name": "Kapuchon"}], + "external_urls": {"spotify": "https://open.spotify.com/track/abc123"}, + } + ] + } + }, + status=200, + ) + result = spotify_client.search_track("fake-token", "Kapuchon Hot Sauce") + assert result is not None + assert result["id"] == "abc123" + assert result["url"] == "https://open.spotify.com/track/abc123" + + @responses.activate + def test_returns_none_when_no_results(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"tracks": {"items": []}}, + status=200, + ) + result = spotify_client.search_track("fake-token", "no-match") + assert result is None + + @responses.activate + def test_sends_bearer_token(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"tracks": {"items": []}}, + status=200, + ) + spotify_client.search_track("my-token", "q") + assert responses.calls[0].request.headers["Authorization"] == "Bearer my-token" + + @responses.activate + def test_query_params_correct(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"tracks": {"items": []}}, + status=200, + ) + spotify_client.search_track("t", "Kapuchon Hot Sauce") + params = responses.calls[0].request.params + assert params["q"] == "Kapuchon Hot Sauce" + assert params["type"] == "track" + assert params["limit"] == "1" +``` + +- [ ] **Step 2: Verifica fail** + +Run: `python3 -m pytest tests/test_spotify_client.py -v` +Expected: FAIL con `AttributeError: module 'core.spotify_client' has no attribute 'search_track'` + +- [ ] **Step 3: Aggiungi search_track a core/spotify_client.py** + +Aggiungi in fondo a `core/spotify_client.py`: + +```python +def search_track(token: str, query: str) -> dict | None: + """Cerca un brano su Spotify e ritorna il primo match. + + Ritorna un dict con almeno {id, url, name, artists} oppure None se nessun match. + Solleva requests.HTTPError su errori server / auth. + """ + resp = requests.get( + "https://api.spotify.com/v1/search", + headers={"Authorization": f"Bearer {token}"}, + params={"q": query, "type": "track", "limit": 1}, + timeout=15, + ) + resp.raise_for_status() + items = resp.json().get("tracks", {}).get("items", []) + if not items: + return None + t = items[0] + return { + "id": t.get("id"), + "url": t.get("external_urls", {}).get("spotify", ""), + "name": t.get("name", ""), + "artists": ", ".join(a.get("name", "") for a in t.get("artists", [])), + } +``` + +- [ ] **Step 4: Verifica test passano** + +Run: `python3 -m pytest tests/test_spotify_client.py -v` +Expected: 4 test PASS + +- [ ] **Step 5: Commit** + +```bash +git add core/spotify_client.py tests/test_spotify_client.py +git -c user.email=info@djluza.com commit -m "spotify: aggiunto search_track() per feature Beatport" +``` + +--- + +## Task 8: Config — nuovo campo beatport_last_genre + version bump + +**Files:** +- Modify: `core/config.py` + +- [ ] **Step 1: Aggiungi campo ai DEFAULTS + version bump** + +Modifica `core/config.py`: + +Cambia: +```python +VERSION = "v1.7.15" +``` +in: +```python +VERSION = "v1.8.0" +``` + +Nel dict `DEFAULTS`, dopo `"theme": "dark",` aggiungi: +```python + # ---- Beatport ---- + "beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato +``` + +- [ ] **Step 2: Verifica carica di default** + +```bash +python3 -c "from core.config import load_config; print(load_config()['beatport_last_genre'])" +``` +Expected: `melodic-house-techno` (o il valore già in config.json se lo hai già) + +- [ ] **Step 3: Commit** + +```bash +git add core/config.py +git -c user.email=info@djluza.com commit -m "config: v1.8.0 + beatport_last_genre nei DEFAULTS" +``` + +--- + +## Task 9: main.py — nuovi metodi Api per bridge JS↔Python + +**Files:** +- Modify: `main.py` + +- [ ] **Step 1: Ispeziona la classe Api esistente** + +Run: `grep -n "class Api\|def [a-z]" main.py | head -40` + +Individua dove si definiscono i metodi esposti a JS (probabile `class Api:` o `class WebviewApi:`), come vengono emessi i log/progress al frontend, e come funziona il pattern di stop. + +Annota qui: +- Nome classe API: ______ +- Metodo per emettere log al JS: ______ (es. `self.window.evaluate_js(...)` o simile) +- Come viene passata `output_dir`: ______ + +- [ ] **Step 2: Aggiungi import in cima a main.py** + +```python +from dataclasses import asdict +from pathlib import Path + +from core import beatport, spotify_client +``` + +Se `beatport` e `spotify_client` sono già importati diversamente (es. `from core.spotify_client import get_access_token`), aggiungili senza duplicare. + +- [ ] **Step 3: Aggiungi metodo beatport_genres** + +Nella classe Api, aggiungi: + +```python + def beatport_genres(self) -> list[dict]: + """Ritorna la lista dei generi disponibili per il dropdown.""" + return beatport.list_genres() +``` + +- [ ] **Step 4: Aggiungi metodo beatport_fetch_chart** + +```python + def beatport_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict: + """Fetches la Top 100 per il genere. Ritorna {ok, tracks} o {ok:False, error, message}.""" + # Salva ultimo genere per la prossima apertura + try: + cfg = load_config() + cfg["beatport_last_genre"] = slug + save_config(cfg) + except Exception: + pass # non bloccare per errori di salvataggio config + + try: + tracks = beatport.fetch_top100(slug, force_refresh=force_refresh) + except ValueError as e: + return {"ok": False, "error": "invalid_genre", "message": str(e)} + except beatport.BeatportUnreachableError as e: + return {"ok": False, "error": "unreachable", "message": str(e)} + except beatport.BeatportParseError as e: + return {"ok": False, "error": "parse", "message": str(e)} + + return {"ok": True, "tracks": [asdict(t) for t in tracks]} +``` + +`load_config` e `save_config` sono già importati da `core.config` — verifica. + +- [ ] **Step 5: Aggiungi metodo beatport_check_existing** + +```python + def beatport_check_existing(self, tracks: list[dict], genre_name: str) -> list[bool]: + """Per ogni track ritorna True se esiste già un file .mp3 corrispondente + nella cartella output di quel genere.""" + cfg = load_config() + out_dir = Path(cfg["output_dir"]) / "Beatport" / genre_name + if not out_dir.exists(): + return [False] * len(tracks) + + # Usa lo stesso naming del downloader esistente: "Artista - Titolo.mp3" + # (verifica il pattern reale ispezionando core/downloader.py) + existing = {p.stem for p in out_dir.glob("*.mp3")} + result = [] + for t in tracks: + expected_stem = f"{t['artists']} - {t['title']}" + # Match esatto o fuzzy prefisso (il downloader può aggiungere " (Extended)" ecc.) + found = any(expected_stem in name or name.startswith(expected_stem) for name in existing) + result.append(found) + return result +``` + +**Nota:** l'esatto naming pattern lo produce il downloader esistente. Verifica in `core/downloader.py` come si chiamano i file di output (potrebbe usare `%(title)s` di yt-dlp). Se diverso, adatta `expected_stem`. + +- [ ] **Step 6: Aggiungi metodo beatport_download_selected** + +```python + def beatport_download_selected(self, tracks: list[dict], genre_name: str) -> None: + """Scarica i brani selezionati riusando il flusso Spotify search → downloader.""" + from core import downloader # locale per evitare circular imports + + cfg = load_config() + out_dir = Path(cfg["output_dir"]) / "Beatport" / genre_name + out_dir.mkdir(parents=True, exist_ok=True) + + client_id = cfg.get("client_id", "").strip() + client_secret = cfg.get("client_secret", "").strip() + if not client_id or not client_secret: + self._emit_log("✗ Credenziali Spotify mancanti — vai su Impostazioni") + return + + try: + token = spotify_client.get_access_token(client_id, client_secret) + except Exception as e: + self._emit_log(f"✗ Errore auth Spotify: {e}") + return + + downloader.reset_stop() + total = len(tracks) + for i, t in enumerate(tracks, 1): + if downloader._stop_event.is_set(): + self._emit_log(f"[{i}/{total}] Interrotto dall'utente") + return + + query = f"{t['artists']} {t['title']}" + self._emit_log(f"[{i}/{total}] Cerco su Spotify: {query}") + try: + found = spotify_client.search_track(token, query) + except Exception as e: + self._emit_log(f"[{i}/{total}] ✗ Errore Spotify: {e}") + continue + + if not found: + self._emit_log(f"[{i}/{total}] ✗ Non trovato su Spotify") + continue + + self._emit_log(f"[{i}/{total}] Download da YouTube: {found['name']}") + try: + downloader.download_from_spotify_url( # nome esatto lo verifichi + found["url"], + output_dir=str(out_dir), + on_progress=self._emit_progress, + ) + self._emit_log(f"[{i}/{total}] ✓ {t['title']}") + except Exception as e: + self._emit_log(f"[{i}/{total}] ✗ Download fallito: {e}") +``` + +**⚠️ Da adattare:** +- `self._emit_log(...)` e `self._emit_progress(...)`: sostituisci coi metodi reali usati dagli altri tab per pushare log/progress al JS (visti nella Task 9 Step 1) +- `downloader.download_from_spotify_url(...)`: sostituisci con il nome effettivo della funzione pubblica del downloader (ispeziona `core/downloader.py`) + +- [ ] **Step 7: Smoke test manuale del bridge** + +Avvia l'app e apri la console browser (menu View del webview): + +```javascript +window.pywebview.api.beatport_genres().then(console.log) +``` +Expected: array di 14+ oggetti `{slug, id, name}` + +```javascript +window.pywebview.api.beatport_fetch_chart("melodic-house-techno").then(console.log) +``` +Expected: `{ok: true, tracks: [ … 100 oggetti … ]}` + +Se fallisce: leggi il messaggio, sistema il metodo Python, riavvia l'app. + +- [ ] **Step 8: Commit** + +```bash +git add main.py +git -c user.email=info@djluza.com commit -m "main: 4 metodi Api per feature Beatport" +``` + +--- + +## Task 10: UI — HTML tab + section + +**Files:** +- Modify: `webui/index.html` + +- [ ] **Step 1: Trova la tab bar e i pattern esistenti** + +Run: `grep -n 'data-tab\|class=".*tab' webui/index.html | head -30` + +Individua: +- La `