Merge feat/traxsource-charts: MusicTools v1.9.0
Nuova tab Traxsource per caricare la Top 100 mensile per genere. 23 generi, bypass Cloudflare via curl_cffi session, HTML scraping con BeautifulSoup, cache 15min. Riusa pipeline download + tag ID3. 29 nuovi test unitari, 90/90 test verdi totali.
This commit is contained in:
commit
2689f686ec
12 files changed
+11701
-33
No files matched your search
+105
-1
@@ -15,7 +15,7 @@ from typing import Any, Callable, Optional
|
||||
import requests
|
||||
|
||||
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
|
||||
from core import beatport, spotify_client
|
||||
from core import beatport, spotify_client, traxsource
|
||||
from core import license as license_mod
|
||||
from core.downloader import (
|
||||
download_playlist,
|
||||
@@ -1165,6 +1165,110 @@ class Api:
|
||||
"subfolder": subfolder,
|
||||
})
|
||||
|
||||
# ================================================================
|
||||
# Traxsource charts
|
||||
# ================================================================
|
||||
def _traxsource_output_dir(self, out_root: str, genre_name: str) -> Path:
|
||||
"""Cartella dove finiscono i file Traxsource. Coerente con la
|
||||
sanitizzazione di `start_tracks_download` (slash -> underscore)."""
|
||||
safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip()
|
||||
subfolder = f"Traxsource_{safe_genre}" if safe_genre else "Traxsource"
|
||||
return Path(out_root) / subfolder
|
||||
|
||||
def traxsource_genres(self) -> list:
|
||||
"""Lista dei generi Traxsource per il dropdown UI."""
|
||||
return traxsource.list_genres()
|
||||
|
||||
def traxsource_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
|
||||
"""Fetches la Top 100 corrente per il genere. Salva l'ultimo genere in config."""
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["traxsource_last_genre"] = slug
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
tracks = traxsource.fetch_top100(slug, force_refresh=force_refresh)
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "invalid_genre", "message": str(e)}
|
||||
except traxsource.TraxsourceUnreachableError as e:
|
||||
return {"ok": False, "error": "unreachable", "message": str(e)}
|
||||
except traxsource.TraxsourceParseError as e:
|
||||
return {"ok": False, "error": "parse", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
|
||||
|
||||
def traxsource_check_existing(self, tracks: list, genre_name: str) -> list:
|
||||
"""True per ogni track già presente nella cartella output Traxsource.
|
||||
Match euristico: filename (senza extension) deve contenere sia il titolo
|
||||
che il primo artista (case-insensitive)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
|
||||
out_dir = self._traxsource_output_dir(out_root, genre_name)
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").lower().strip()
|
||||
artists = (t.get("artists") or "")
|
||||
first_artist = artists.split(",")[0].split("&")[0].strip().lower()
|
||||
if not title or not first_artist:
|
||||
result.append(False)
|
||||
continue
|
||||
result.append(any(
|
||||
(title in stem and first_artist in stem)
|
||||
for stem in existing_stems
|
||||
))
|
||||
return result
|
||||
|
||||
def traxsource_download_selected(self, tracks: list, genre_name: str) -> dict:
|
||||
"""Converte i TraxsourceTrack in tracklist compatibile con
|
||||
start_tracks_download + costruisce metadata paralleli per il tagger ID3
|
||||
(album=label Traxsource, date=YYYY-MM corrente, genre=nome genere)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
target = self._traxsource_output_dir(out_root, genre_name)
|
||||
subfolder = target.name # es. "Traxsource_Tech House"
|
||||
|
||||
import datetime as _dt
|
||||
current_month = _dt.date.today().strftime("%Y-%m")
|
||||
|
||||
converted: list = []
|
||||
metadata: list = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").strip()
|
||||
artists = (t.get("artists") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
converted.append({"name": title, "artist": artists})
|
||||
metadata.append({
|
||||
"title": title,
|
||||
"artist": artists,
|
||||
"album": (t.get("label") or "").strip() or f"Traxsource Top 100 {current_month}",
|
||||
"date": current_month,
|
||||
"genre": genre_name,
|
||||
"cover_url": (t.get("cover_url_large") or t.get("image_url") or "").strip(),
|
||||
})
|
||||
|
||||
if not converted:
|
||||
return {"ok": False, "error": "Nessun brano valido"}
|
||||
|
||||
return self.start_tracks_download({
|
||||
"tracks": converted,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
"metadata": metadata,
|
||||
})
|
||||
|
||||
# ================================================================
|
||||
# Music Search — Spotify + YouTube
|
||||
# ================================================================
|
||||
|
||||
+3
-1
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.8.6"
|
||||
VERSION = "v1.9.0"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
@@ -72,6 +72,8 @@ DEFAULTS = {
|
||||
"theme": "dark",
|
||||
# ---- Beatport ----
|
||||
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
|
||||
# ---- Traxsource ----
|
||||
"traxsource_last_genre": "tech-house",
|
||||
# ---- Music Search (Spotify + YouTube) ----
|
||||
"spotify_search_last_query": "",
|
||||
"spotify_search_artist_mode": False,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Fetch Top 100 Traxsource per genere.
|
||||
|
||||
Approccio: session curl_cffi (bypass CF via cookie) + BeautifulSoup HTML scraping.
|
||||
Spec: docs/superpowers/specs/2026-08-01-traxsource-charts-design.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
# Generi musicali Traxsource (slug -> (id, display_name)).
|
||||
# Aggiornato via scripts/refresh_traxsource_genres.py. Esclusi "sounds/samples/loops",
|
||||
# "acapella", "beats", "efx-dj-tools", "stems" (non generi ma tipi di prodotto).
|
||||
GENRES: dict = {
|
||||
"afro-house": (27, "Afro House"),
|
||||
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
|
||||
"broken-beat-nu-jazz": (2, "Broken Beat / Nu Jazz"),
|
||||
"classic-house": (12, "Classic House"),
|
||||
"deep-house": (13, "Deep House"),
|
||||
"drum-and-bass": (31, "Drum & Bass"),
|
||||
"electro-house": (11, "Electro House"),
|
||||
"electronica": (5, "Electronica"),
|
||||
"garage": (29, "Garage"),
|
||||
"house": (4, "House"),
|
||||
"jackin-house": (15, "Jackin House"),
|
||||
"leftfield": (14, "Leftfield"),
|
||||
"lounge-chill-out": (1, "Lounge / Chill Out"),
|
||||
"melodic-progressive-house": (19, "Melodic / Progressive House"),
|
||||
"minimal-deep-tech": (16, "Minimal / Deep Tech"),
|
||||
"nu-disco-indie-dance": (17, "Nu Disco / Indie Dance"),
|
||||
"pop-dance": (32, "Pop Dance"),
|
||||
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
|
||||
"soul-funk-disco": (3, "Soul / Funk / Disco"),
|
||||
"soulful-house": (24, "Soulful House"),
|
||||
"tech-house": (18, "Tech House"),
|
||||
"techno": (20, "Techno"),
|
||||
"world": (30, "World"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraxsourceTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str
|
||||
artists: str
|
||||
label: str
|
||||
traxsource_id: int
|
||||
slug: str
|
||||
image_url: str = ""
|
||||
cover_url_large: str = ""
|
||||
|
||||
|
||||
def list_genres() -> list:
|
||||
result = [
|
||||
{"slug": slug, "id": gid, "name": name}
|
||||
for slug, (gid, name) in GENRES.items()
|
||||
]
|
||||
result.sort(key=lambda g: g["name"].casefold())
|
||||
return result
|
||||
|
||||
|
||||
class TraxsourceError(Exception):
|
||||
"""Base per errori Traxsource."""
|
||||
|
||||
|
||||
class TraxsourceUnreachableError(TraxsourceError):
|
||||
"""Rete / 5xx dopo retry."""
|
||||
|
||||
|
||||
class TraxsourceParseError(TraxsourceError):
|
||||
"""HTML ricevuto ma non conforme allo schema atteso."""
|
||||
|
||||
|
||||
_MIX_PAREN_RE = re.compile(r"^(.*)\s*\(([^()]+)\)\s*$")
|
||||
_SIZE_RE = re.compile(r"/\d+x\d+/")
|
||||
|
||||
|
||||
def _split_title_mix(full_title: str) -> tuple:
|
||||
"""Estrae mix dalle parentesi finali. 'Foo (Extended Mix)' -> ('Foo', 'Extended Mix').
|
||||
Se non ci sono parentesi finali, mix = ''."""
|
||||
if not full_title:
|
||||
return ("", "")
|
||||
m = _MIX_PAREN_RE.match(full_title.strip())
|
||||
if m:
|
||||
return (m.group(1).strip(), m.group(2).strip())
|
||||
return (full_title.strip(), "")
|
||||
|
||||
|
||||
def _format_artists(names: list) -> str:
|
||||
"""['A', 'B', 'C'] -> 'A, B & C'. Strips whitespace."""
|
||||
clean = [n.strip() for n in names if n and n.strip()]
|
||||
if not clean:
|
||||
return ""
|
||||
if len(clean) == 1:
|
||||
return clean[0]
|
||||
return ", ".join(clean[:-1]) + " & " + clean[-1]
|
||||
|
||||
|
||||
def _large_cover(url: str) -> str:
|
||||
"""Sostituisce /NxN/ nel path con /500x500/. Se pattern assente, ritorna invariato."""
|
||||
if not url:
|
||||
return ""
|
||||
return _SIZE_RE.sub("/500x500/", url)
|
||||
|
||||
|
||||
_TOP100_LINK_RE = re.compile(r'href="(/title/\d+/top-100-[a-z0-9-]+)"')
|
||||
|
||||
|
||||
def _discover_top100_url(genre_html: str) -> str:
|
||||
"""Estrae il path relativo della playlist Top 100 corrente dalla pagina di un genere."""
|
||||
m = _TOP100_LINK_RE.search(genre_html)
|
||||
if not m:
|
||||
raise TraxsourceParseError("link Top 100 non trovato nella pagina genere")
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _parse_tracks(html: str) -> list:
|
||||
"""Parsa la pagina Top 100 (title playlist) e ritorna list[TraxsourceTrack].
|
||||
|
||||
Selettori (verificati su fixture tech-house 2026-07):
|
||||
row = div.trk-row.play-trk (data-trid=<int>)
|
||||
position = div.tnum (inside div.tnum-pos)
|
||||
title <a> = div.trk-cell.title a[href^="/track/"]
|
||||
version = span.version (contiene child span.duration da rimuovere)
|
||||
artists = a.com-artists (uno o piu)
|
||||
label <a> = div.trk-cell.label a
|
||||
cover img = div.trk-cell.thumb img (src /scripts/image.php/52x52/...)
|
||||
"""
|
||||
# Lazy import — bs4 non e' hard-dep del modulo (importato solo quando serve).
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
rows = soup.select("div.trk-row.play-trk")
|
||||
if not rows:
|
||||
raise TraxsourceParseError("nessuna track (div.trk-row.play-trk) trovata")
|
||||
|
||||
out: list = []
|
||||
for i, row in enumerate(rows, 1):
|
||||
try:
|
||||
trid = int(row.get("data-trid") or 0)
|
||||
|
||||
pos_el = row.select_one("div.tnum")
|
||||
position = i # fallback su enumerate se pos manca / non e' un numero
|
||||
if pos_el:
|
||||
pos_txt = pos_el.get_text(strip=True)
|
||||
if pos_txt.isdigit():
|
||||
position = int(pos_txt)
|
||||
|
||||
title_a = row.select_one('div.trk-cell.title a[href^="/track/"]')
|
||||
if not title_a:
|
||||
continue
|
||||
title = title_a.get_text(strip=True)
|
||||
href = title_a.get("href") or ""
|
||||
slug = href.rsplit("/", 1)[-1]
|
||||
|
||||
# Mix version: contenuto di span.version, escluso span.duration
|
||||
mix = ""
|
||||
version_el = row.select_one("span.version")
|
||||
if version_el:
|
||||
dur_el = version_el.select_one("span.duration")
|
||||
if dur_el:
|
||||
dur_el.extract()
|
||||
mix = version_el.get_text(strip=True)
|
||||
|
||||
artist_names = [a.get_text(strip=True) for a in row.select("a.com-artists")]
|
||||
artists = _format_artists(artist_names)
|
||||
|
||||
label_a = row.select_one("div.trk-cell.label a")
|
||||
label = label_a.get_text(strip=True) if label_a else ""
|
||||
|
||||
img = row.select_one('div.trk-cell.thumb img[src*="/scripts/image.php/"]')
|
||||
image_url = (img.get("src") or "") if img else ""
|
||||
cover_large = _large_cover(image_url)
|
||||
|
||||
out.append(TraxsourceTrack(
|
||||
position=position,
|
||||
title=title,
|
||||
mix=mix,
|
||||
artists=artists,
|
||||
label=label,
|
||||
traxsource_id=trid,
|
||||
slug=slug,
|
||||
image_url=image_url,
|
||||
cover_url_large=cover_large,
|
||||
))
|
||||
except Exception as e:
|
||||
raise TraxsourceParseError(f"errore parse track[{i}]: {e}") from e
|
||||
return out
|
||||
|
||||
|
||||
# --- fetch_top100 con session curl_cffi + retry + cache ------------------
|
||||
|
||||
_IMPERSONATE = "chrome131"
|
||||
_REQUEST_TIMEOUT = 15
|
||||
_MAX_ATTEMPTS = 3
|
||||
_BACKOFF_SEC = [1, 3]
|
||||
_CACHE_TTL_SEC = 15 * 60
|
||||
|
||||
_cache: dict = {}
|
||||
_session_singleton = None
|
||||
|
||||
|
||||
def _session():
|
||||
"""Ritorna la Session curl_cffi singleton, preriscaldata con GET a /."""
|
||||
global _session_singleton
|
||||
if _session_singleton is None:
|
||||
from curl_cffi import requests as _cffi
|
||||
_session_singleton = _cffi.Session(impersonate=_IMPERSONATE)
|
||||
try:
|
||||
_session_singleton.get("https://www.traxsource.com/", timeout=_REQUEST_TIMEOUT)
|
||||
except Exception:
|
||||
pass # cookie CF possono arrivare comunque
|
||||
return _session_singleton
|
||||
|
||||
|
||||
def _do_get(session, url: str) -> str:
|
||||
"""GET con retry + backoff. Include Referer per pagine interne."""
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
resp = session.get(
|
||||
url,
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
headers={"Referer": "https://www.traxsource.com/"},
|
||||
)
|
||||
if resp.status_code >= 500 or resp.status_code == 403:
|
||||
raise Exception(f"HTTP {resp.status_code}")
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt < _MAX_ATTEMPTS - 1:
|
||||
time.sleep(_BACKOFF_SEC[attempt])
|
||||
raise TraxsourceUnreachableError(
|
||||
f"Traxsource irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}"
|
||||
)
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list:
|
||||
"""Fetches Top 100 corrente per il genere.
|
||||
|
||||
1. Verifica slug in GENRES
|
||||
2. Fetch pagina genre -> _discover_top100_url
|
||||
3. Fetch pagina Top 100 -> _parse_tracks
|
||||
4. Cache 15 min
|
||||
|
||||
Raises:
|
||||
ValueError: slug non in GENRES
|
||||
TraxsourceUnreachableError: rete/5xx dopo retry
|
||||
TraxsourceParseError: HTML non conforme
|
||||
"""
|
||||
if slug not in GENRES:
|
||||
raise ValueError(f"slug genere non valido: {slug!r}")
|
||||
|
||||
now = time.time()
|
||||
if not force_refresh:
|
||||
cached = _cache.get(slug)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL_SEC:
|
||||
return cached[1]
|
||||
|
||||
gid, _name = GENRES[slug]
|
||||
sess = _session()
|
||||
|
||||
genre_url = f"https://www.traxsource.com/genre/{gid}/{slug}"
|
||||
genre_html = _do_get(sess, genre_url)
|
||||
top100_path = _discover_top100_url(genre_html)
|
||||
|
||||
top100_url = "https://www.traxsource.com" + top100_path
|
||||
top100_html = _do_get(sess, top100_url)
|
||||
tracks = _parse_tracks(top100_html)
|
||||
|
||||
_cache[slug] = (now, tracks)
|
||||
return tracks
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,256 @@
|
||||
# Traxsource Charts — Design Spec
|
||||
|
||||
- **Data:** 2026-08-01
|
||||
- **Autore:** LuZa + Claude
|
||||
- **Stato:** Approvato, pronto per implementation plan
|
||||
- **Target release:** MusicTools v1.9.0
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Nuova tab "▲ Traxsource" nell'app che permette di:
|
||||
|
||||
1. Scegliere un genere musicale da un elenco di ~15 generi Traxsource
|
||||
2. Caricare la **Top 100** ufficiale del mese corrente per quel genere
|
||||
3. Anteprima checkbox + download riusando il pipeline esistente (Spotify search → yt-dlp → tag ID3 + cover)
|
||||
|
||||
Sostituisce l'attività manuale di copiare/incollare tracklist Traxsource.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Chart diverse dalla Top 100 (Must Have, Just Added, ecc.)
|
||||
- Ricerca artista/label specifica su Traxsource
|
||||
- Preview audio (Traxsource ha DRM)
|
||||
- Duration tracks (non è nell'HTML della lista; sarebbe una richiesta extra per track)
|
||||
- Login/account Traxsource
|
||||
|
||||
## Ricognizione tecnica (fatta 2026-08-01)
|
||||
|
||||
- **Cloudflare Managed Challenge attivo** su tutte le pagine tranne home e /top. Bypass: session-based con `curl_cffi.Session(impersonate='chrome131')`, prima GET a `/`, poi le pagine sensibili con `Referer: https://www.traxsource.com/`.
|
||||
- **Non è Next.js** → no `__NEXT_DATA__`. HTML server-rendered.
|
||||
- **Struttura URL:**
|
||||
- Genre index: `/genre/<id>/<slug>` — es. `/genre/18/tech-house`. Contiene solo 10 top track più altri widget
|
||||
- Top 100 corrente: `/title/<title_id>/top-100-<genre>-of-<month>-<year>` — es. `/title/2847986/top-100-tech-house-of-july-2026`. Contiene 100 track completi in un unico documento (~394KB)
|
||||
- **Il title_id cambia ogni mese.** Non è hardcodabile. Va scoperto dinamicamente
|
||||
- **Discovery Top 100 URL:** nella pagina `/genre/<id>/<slug>` c'è (almeno un) `<a href="/title/<id>/top-100-<slug>-of-<month>-<year>">`. Estrai col regex `href="(/title/\d+/top-100-[a-z0-9-]+)"`
|
||||
- **Track markup (stabile):**
|
||||
```html
|
||||
<div data-trid="14842066" class="top-item play-trk ptk-14842066">
|
||||
<div class="ttib position">1</div>
|
||||
<div class="image"><img src="https://.../52x52/HASH.jpg" /></div>
|
||||
<div class="ttib info">
|
||||
<a href="/track/14842066/badman-sound-extended-mix" class="com-title">Badman Sound (Extended Mix)</a>
|
||||
<a href="/artist/92248/hannah-wants" class="com-artists">Hannah Wants</a>,
|
||||
<a href="/artist/321517/trace" class="com-artists">Trace</a>
|
||||
<a href="/label/325/nervous" class="com-label">Nervous</a>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
Class names sono stabili (semantici, non generati da bundler).
|
||||
- **Cover art:** URL contiene la size (`/52x52/`). Sostituendo con `/500x500/` si ottiene la versione grande per tagging ID3.
|
||||
|
||||
## Approccio scelto
|
||||
|
||||
- Session `curl_cffi` con `impersonate='chrome131'`. Cookie CF conservati automaticamente. Preriscaldamento con GET a `/` al primo utilizzo.
|
||||
- Parser: `BeautifulSoup4` con selettori CSS puliti — `div.top-item.play-trk` per righe, `a.com-title` per titolo, `a.com-artists` per artisti, `a.com-label` per label, `div.ttib.position` per rank, `img` per cover URL.
|
||||
- Regex sul titolo per estrarre "mix name" tra parentesi: `Song Title (Extended Mix)` → title="Song Title", mix="Extended Mix".
|
||||
- Cache in-memory 15 min per la lista, come Beatport.
|
||||
- Genere → (numeric_id, slug, display_name) map hardcoded (estratti dalla home /top).
|
||||
|
||||
**Alternative scartate:**
|
||||
- `requests` puro: bloccato da CF, verificato
|
||||
- API interna Traxsource: non esistono API pubbliche gratuite documentate; reverse engineering rischio ban
|
||||
- Playwright: overkill (~100MB dep) quando session + BeautifulSoup basta
|
||||
|
||||
## Architettura
|
||||
|
||||
### Nuovo modulo `core/traxsource.py`
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Mappa slug → (numeric_id, display_name)
|
||||
# Verificati 2026-08-01 dalla home /top di Traxsource
|
||||
GENRES: dict = {
|
||||
"afro-house": (33, "Afro House"),
|
||||
"afro-latin-brazilian": (23, "Afro / Latin / Brazilian"),
|
||||
"amapiano": (37, "Amapiano"),
|
||||
"bass-club": (25, "Bass / Club"),
|
||||
"breaks-uk-bass": (14, "Breaks / UK Bass"),
|
||||
"classic-house": (12, "Classic House"),
|
||||
"deep-house": (13, "Deep House"),
|
||||
"dj-tools": (16, "DJ Tools"),
|
||||
"downtempo-nu-disco-indie-dance": (22, "Downtempo / Nu Disco / Indie Dance"),
|
||||
"electronica": (5, "Electronica"),
|
||||
"electro-house": (11, "Electro House"),
|
||||
"funky-jackin-groovy": (15, "Funky / Jackin / Groovy"),
|
||||
"garage": (29, "Garage"),
|
||||
"house": (1, "House"),
|
||||
"lounge-chill-out": (10, "Lounge / Chill Out"),
|
||||
"melodic-house-techno-progressive-house": (34, "Melodic House / Techno / Progressive House"),
|
||||
"minimal-deep-tech": (27, "Minimal / Deep Tech"),
|
||||
"organic-house-downtempo": (36, "Organic House / Downtempo"),
|
||||
"progressive-house": (28, "Progressive House"),
|
||||
"r-and-b-hip-hop": (6, "R&B / Hip Hop"),
|
||||
"soulful-house": (24, "Soulful House"),
|
||||
"tech-house": (18, "Tech House"),
|
||||
"techno-peak-time-driving": (26, "Techno (Peak Time / Driving)"),
|
||||
"techno-raw-deep-hypnotic": (32, "Techno (Raw / Deep / Hypnotic)"),
|
||||
"trance": (30, "Trance"),
|
||||
"world-reggae": (21, "World / Reggae"),
|
||||
}
|
||||
# NOTA: la mappa è enumerata in fase di implementazione fetchando /top con curl_cffi
|
||||
# e cercando href="/genre/<id>/<slug>". Vedi Task 1 del plan.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraxsourceTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str
|
||||
artists: str # "A, B & C" formattato
|
||||
label: str
|
||||
traxsource_id: int
|
||||
slug: str
|
||||
image_url: str = "" # URL thumbnail (per UI)
|
||||
cover_url_large: str = "" # URL cover 500x500 (per tagging)
|
||||
|
||||
|
||||
class TraxsourceError(Exception): pass
|
||||
class TraxsourceUnreachableError(TraxsourceError): pass
|
||||
class TraxsourceParseError(TraxsourceError): pass
|
||||
|
||||
|
||||
def list_genres() -> list:
|
||||
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list[TraxsourceTrack]:
|
||||
"""Fetches Top 100 corrente per il genere.
|
||||
|
||||
1. Scopre l'URL della playlist Top 100 del mese via GET /genre/<id>/<slug>
|
||||
2. Fetch della playlist /title/<id>/top-100-...
|
||||
3. Parse HTML con BeautifulSoup
|
||||
4. Cache in-memory 15 min
|
||||
|
||||
Raises:
|
||||
ValueError se slug non in GENRES
|
||||
TraxsourceUnreachableError su rete/5xx dopo retry
|
||||
TraxsourceParseError su HTML non conforme (title link non trovato, meno di 50 track, ecc.)
|
||||
"""
|
||||
```
|
||||
|
||||
**Internals:**
|
||||
- `_session()` singleton — inizializza `curl_cffi.Session(impersonate='chrome131')` e fa GET a `/` una volta per riscaldare CF cookies. Riusata per tutte le fetch successive.
|
||||
- `_discover_top100_url(session, slug) → str` — fetcha `/genre/<id>/<slug>`, regex per il link Top 100
|
||||
- `_parse_tracks(html) → list[TraxsourceTrack]` — BeautifulSoup, itera `div.top-item.play-trk`, estrae campi
|
||||
- `_split_title_mix(full_title) → (title, mix)` — parsa `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")`. Se no parentesi, mix = "".
|
||||
- `_format_artists(anchors) → str` — join `[a.text for a in anchors]` con formattazione "A, B & C"
|
||||
- `_large_cover(url) → str` — sostituisce `/52x52/` con `/500x500/` nel path
|
||||
|
||||
**Retry + timeout:** riusa gli stessi pattern di `core/beatport.py`: 3 tentativi con backoff [1, 3]s, timeout 15s.
|
||||
|
||||
### Nuovi metodi in `api/bridge.py`
|
||||
|
||||
Paralleli ai metodi Beatport (stesso schema):
|
||||
|
||||
- `traxsource_genres() → list[dict]`
|
||||
- `traxsource_fetch_chart(slug, force_refresh) → {ok, tracks} | {ok:false, error, message}` — salva anche `traxsource_last_genre` in config
|
||||
- `traxsource_check_existing(tracks, genre_name) → list[bool]` — check in `<output_dir>/Traxsource_<genre>/`
|
||||
- `traxsource_download_selected(tracks, genre_name) → dict` — costruisce metadata (title/artist/album=label/date=YYYY-MM/genre=display_name/cover_url) e chiama `start_tracks_download` riusando il pipeline con tagging ID3
|
||||
|
||||
**Metadata per il tagger:**
|
||||
- title, artist: dai campi track
|
||||
- album: label Traxsource (best proxy — non c'è "album" per singole tracce)
|
||||
- date: mese/anno corrente (dato che la Top 100 è mensile)
|
||||
- genre: display_name del genere
|
||||
- cover_url: `cover_url_large` (500x500)
|
||||
|
||||
### Frontend
|
||||
|
||||
Nuova tab in sidebar dopo YouTube (o Beatport):
|
||||
```html
|
||||
<button class="nav-item" data-view="traxsource" data-feature="audio">
|
||||
<span class="nav-icon">▲</span><span>Traxsource</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Section `#view-traxsource` con struttura identica a Beatport (dropdown genere + Carica Top 100 + tabella + toolbar + log). Riusa tutte le classi `.beatport-*` per lo stile.
|
||||
|
||||
Modulo `TraxsourceUI` in `webui/js/app.js` — copia diretta di `BeatportUI` con selettori cambiati (`traxsource-*` invece di `beatport-*`) e chiamate API alle nuove funzioni.
|
||||
|
||||
**Ordinamento colonne** (sortable) e **cover art** già supportati riusando la stessa struttura tabella + modulo helper `_sortPairs`/`_bindSortableHeaders`/`_updateSortArrows` esistenti.
|
||||
|
||||
### Persistenza
|
||||
|
||||
Nuovo campo in `core/config.py::DEFAULTS`:
|
||||
```python
|
||||
"traxsource_last_genre": "tech-house",
|
||||
```
|
||||
|
||||
### Dipendenze
|
||||
|
||||
- `beautifulsoup4` — nuova dep runtime. Aggiungere in `requirements.txt`. Serve `--collect-data bs4` in build_windows.py + build_macos.py? Verificare — bs4 di solito no.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit tests `tests/test_traxsource.py` (senza rete, con fixture HTML):**
|
||||
|
||||
Serve una fixture HTML reale della pagina Top 100 (~400KB). Task 1 la genera con `curl_cffi` come fatto per Beatport (`beatport_melodic_top100.html`).
|
||||
|
||||
| Test | Verifica |
|
||||
|---|---|
|
||||
| `test_list_genres_shape` | Lista dict con {slug, id, name}, ordinati |
|
||||
| `test_parse_top100_extracts_100_tracks` | Fixture HTML → 100 track |
|
||||
| `test_parse_positions_sequential` | 1..100 senza buchi |
|
||||
| `test_parse_track_shape` | Ogni track ha title, artists, label, cover URLs non vuoti |
|
||||
| `test_split_title_mix_with_parens` | `"Foo (Extended Mix)"` → `("Foo", "Extended Mix")` |
|
||||
| `test_split_title_mix_no_parens` | `"Foo"` → `("Foo", "")` |
|
||||
| `test_format_artists_multi` | `["A", "B", "C"]` → `"A, B & C"` |
|
||||
| `test_large_cover_url_substitution` | `.../52x52/x.jpg` → `.../500x500/x.jpg` |
|
||||
| `test_discover_top100_url_returns_playlist_link` | HTML genre stub → estrae `/title/N/top-100-...` |
|
||||
| `test_fetch_top100_invalid_slug_raises_value_error` | slug non in GENRES → ValueError early |
|
||||
|
||||
Mock HTTP con `unittest.mock.patch("core.traxsource._session")` (patch della session singleton).
|
||||
|
||||
**Test manuale end-to-end:**
|
||||
1. Avvia app → tab Traxsource
|
||||
2. Selezione "Tech House" → Carica Top 100 → tabella 100 righe in <5s
|
||||
3. Verifica cover art (thumbnail nella tabella)
|
||||
4. Deseleziona 90, tieni 10 → Scarica selezionati
|
||||
5. File in `MUSICA/Traxsource_Tech House/` con tag ID3 completi (title, artist, album=label, genre=Tech House, cover 500x500)
|
||||
6. Ricarica stesso genere → cache hit
|
||||
7. Riavvio → ultimo genere ricordato
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Branch `feat/traxsource-charts`
|
||||
2. Bump `core/config.py::VERSION` → `v1.9.0` (minor: nuova tab visibile)
|
||||
3. Note release `/tmp/notes-v1.9.0.md`
|
||||
4. Merge → tag → CI → server DB update (stesso flusso di sempre)
|
||||
|
||||
## Struttura file impattati
|
||||
|
||||
**Nuovi:**
|
||||
- `core/traxsource.py` (~250 LOC)
|
||||
- `tests/test_traxsource.py` (~200 LOC)
|
||||
- `tests/fixtures/traxsource_tech_house_top100.html` (~400KB)
|
||||
- `scripts/refresh_traxsource_genres.py` (~50 LOC, opzionale)
|
||||
|
||||
**Modificati:**
|
||||
- `requirements.txt` — `beautifulsoup4>=4.12.0`
|
||||
- `build_windows.py` + `build_macos.py` — verificare se serve `--collect-data bs4`
|
||||
- `core/config.py` — VERSION bump + `traxsource_last_genre`
|
||||
- `api/bridge.py` — 4 metodi Api `traxsource_*` (~150 LOC)
|
||||
- `webui/index.html` — nav-item + section (~90 LOC)
|
||||
- `webui/js/app.js` — modulo `TraxsourceUI` (~250 LOC, copia BeatportUI adattata)
|
||||
|
||||
## Considerazioni operative
|
||||
|
||||
- **Fragility scraping:** i class name di Traxsource (`com-title`, `com-artists`, `top-item`, ecc.) sono usati anche nel JS del sito → stabili nel tempo. Rischio: se Traxsource migra a un framework nuovo (React/Next), parser va rifatto.
|
||||
- **Rate limiting:** un solo GET per la genre + un GET per la playlist Top 100 = 2 richieste per caricare la chart. Cache 15 min. Zero rischio ban in uso normale.
|
||||
- **Legal:** stesso perimetro di Beatport — lettura di pagine pubbliche, no ToS violation esplicito.
|
||||
- **Comparabilità con Beatport:** un utente power potrebbe usare entrambe le tab per triangolare le release del mese. UX consistente = curva apprendimento zero.
|
||||
@@ -10,6 +10,7 @@ pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||
curl_cffi>=0.9.0
|
||||
beautifulsoup4>=4.12.0
|
||||
|
||||
# --- dev only ---
|
||||
pytest>=8.0.0
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Estrae la lista dei generi Traxsource dalla pagina /top.
|
||||
Uso: python3 scripts/refresh_traxsource_genres.py > /tmp/tx_genres.txt
|
||||
Poi copia manualmente in core/traxsource.py::GENRES."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
|
||||
def main() -> int:
|
||||
s = requests.Session(impersonate="chrome131")
|
||||
s.get("https://www.traxsource.com/", timeout=15)
|
||||
r = s.get("https://www.traxsource.com/top", timeout=15,
|
||||
headers={"Referer": "https://www.traxsource.com/"})
|
||||
if r.status_code != 200:
|
||||
print(f"HTTP {r.status_code}", file=sys.stderr)
|
||||
return 1
|
||||
# href="/genre/<id>/<slug>"
|
||||
hits = re.findall(r'href="/genre/(\d+)/([a-z0-9-]+)"', r.text)
|
||||
seen = set()
|
||||
for gid, slug in hits:
|
||||
if slug in seen:
|
||||
continue
|
||||
seen.add(slug)
|
||||
# Display name = slug con capitalizzazione euristica (l'utente lo raffina a mano)
|
||||
name = " ".join(w.capitalize() for w in slug.replace("-", " ").split())
|
||||
print(f' "{slug}": ({gid}, "{name}"),')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+2882
File diff suppressed because it is too large.
Load diff
+6332
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,217 @@
|
||||
"""Test per core.traxsource."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core import traxsource
|
||||
|
||||
|
||||
class TestListGenres:
|
||||
def test_returns_list_of_dicts(self):
|
||||
result = traxsource.list_genres()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 10
|
||||
for g in result:
|
||||
assert set(g.keys()) == {"slug", "id", "name"}
|
||||
assert isinstance(g["slug"], str) and g["slug"]
|
||||
assert isinstance(g["id"], int) and g["id"] > 0
|
||||
assert isinstance(g["name"], str) and g["name"]
|
||||
|
||||
def test_sorted_alphabetically_by_name(self):
|
||||
result = traxsource.list_genres()
|
||||
names = [g["name"] for g in result]
|
||||
assert names == sorted(names, key=str.casefold)
|
||||
|
||||
def test_tech_house_present(self):
|
||||
result = traxsource.list_genres()
|
||||
slugs = [g["slug"] for g in result]
|
||||
assert "tech-house" in slugs
|
||||
|
||||
|
||||
class TestTraxsourceTrack:
|
||||
def test_frozen(self):
|
||||
t = traxsource.TraxsourceTrack(
|
||||
position=1, title="X", mix="Y", artists="A", label="L",
|
||||
traxsource_id=1, slug="x",
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
t.title = "Z"
|
||||
|
||||
def test_defaults(self):
|
||||
t = traxsource.TraxsourceTrack(
|
||||
position=1, title="X", mix="", artists="A", label="",
|
||||
traxsource_id=1, slug="x",
|
||||
)
|
||||
assert t.image_url == ""
|
||||
assert t.cover_url_large == ""
|
||||
|
||||
|
||||
class TestSplitTitleMix:
|
||||
def test_with_parens(self):
|
||||
assert traxsource._split_title_mix("Foo (Extended Mix)") == ("Foo", "Extended Mix")
|
||||
|
||||
def test_without_parens(self):
|
||||
assert traxsource._split_title_mix("Foo") == ("Foo", "")
|
||||
|
||||
def test_multiple_parens_takes_last(self):
|
||||
# es. "Foo (feat. Bar) (Original Mix)" -> mix = "Original Mix"
|
||||
assert traxsource._split_title_mix("Foo (feat. Bar) (Original Mix)") == ("Foo (feat. Bar)", "Original Mix")
|
||||
|
||||
def test_empty(self):
|
||||
assert traxsource._split_title_mix("") == ("", "")
|
||||
|
||||
|
||||
class TestFormatArtists:
|
||||
def test_single(self):
|
||||
assert traxsource._format_artists(["Kapuchon"]) == "Kapuchon"
|
||||
|
||||
def test_two(self):
|
||||
assert traxsource._format_artists(["A", "B"]) == "A & B"
|
||||
|
||||
def test_three(self):
|
||||
assert traxsource._format_artists(["A", "B", "C"]) == "A, B & C"
|
||||
|
||||
def test_empty(self):
|
||||
assert traxsource._format_artists([]) == ""
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert traxsource._format_artists([" A ", " B "]) == "A & B"
|
||||
|
||||
|
||||
class TestLargeCover:
|
||||
def test_substitutes_52x52_with_500x500(self):
|
||||
url = "https://www.traxsource.com/scripts/image.php/52x52/abc.jpg"
|
||||
assert traxsource._large_cover(url) == "https://www.traxsource.com/scripts/image.php/500x500/abc.jpg"
|
||||
|
||||
def test_no_change_when_no_pattern(self):
|
||||
assert traxsource._large_cover("https://example.com/x.jpg") == "https://example.com/x.jpg"
|
||||
|
||||
def test_empty(self):
|
||||
assert traxsource._large_cover("") == ""
|
||||
|
||||
|
||||
class TestExceptions:
|
||||
def test_exceptions_are_subclasses(self):
|
||||
assert issubclass(traxsource.TraxsourceUnreachableError, traxsource.TraxsourceError)
|
||||
assert issubclass(traxsource.TraxsourceParseError, traxsource.TraxsourceError)
|
||||
|
||||
|
||||
class TestDiscoverTop100Url:
|
||||
def test_extracts_link_from_genre_page(self, fixtures_dir):
|
||||
html = (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
|
||||
url = traxsource._discover_top100_url(html)
|
||||
assert url.startswith("/title/")
|
||||
assert "top-100" in url
|
||||
|
||||
def test_raises_when_no_link(self):
|
||||
with pytest.raises(traxsource.TraxsourceParseError, match="Top 100"):
|
||||
traxsource._discover_top100_url("<html>nulla</html>")
|
||||
|
||||
|
||||
class TestParseTracks:
|
||||
def test_parses_100_tracks(self, fixtures_dir):
|
||||
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||
tracks = traxsource._parse_tracks(html)
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_positions_sequential_1_to_100(self, fixtures_dir):
|
||||
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||
tracks = traxsource._parse_tracks(html)
|
||||
positions = [t.position for t in tracks]
|
||||
assert positions == list(range(1, 101))
|
||||
|
||||
def test_track_shape(self, fixtures_dir):
|
||||
html = (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||
tracks = traxsource._parse_tracks(html)
|
||||
first = tracks[0]
|
||||
assert first.title
|
||||
assert first.artists
|
||||
assert first.traxsource_id > 0
|
||||
assert first.slug
|
||||
assert first.image_url.startswith("https://")
|
||||
assert first.cover_url_large.startswith("https://")
|
||||
assert "500x500" in first.cover_url_large
|
||||
|
||||
def test_raises_when_no_tracks(self):
|
||||
with pytest.raises(traxsource.TraxsourceParseError, match="track"):
|
||||
traxsource._parse_tracks("<html>vuoto</html>")
|
||||
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _mock_response(text: str, status_code: int = 200) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.text = text
|
||||
resp.status_code = status_code
|
||||
|
||||
def _raise():
|
||||
if status_code >= 400:
|
||||
raise Exception(f"HTTP {status_code}")
|
||||
resp.raise_for_status = _raise
|
||||
return resp
|
||||
|
||||
|
||||
class TestFetchTop100:
|
||||
@pytest.fixture
|
||||
def genre_html(self, fixtures_dir):
|
||||
return (fixtures_dir / "traxsource_tech_house_genre.html").read_text()
|
||||
|
||||
@pytest.fixture
|
||||
def top100_html(self, fixtures_dir):
|
||||
return (fixtures_dir / "traxsource_tech_house_top100.html").read_text()
|
||||
|
||||
def test_success_returns_100_tracks(self, genre_html, top100_html):
|
||||
traxsource._cache.clear()
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.get.side_effect = [
|
||||
_mock_response(genre_html, 200),
|
||||
_mock_response(top100_html, 200),
|
||||
]
|
||||
with patch("core.traxsource._session", return_value=mock_sess):
|
||||
tracks = traxsource.fetch_top100("tech-house")
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_invalid_slug_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="slug"):
|
||||
traxsource.fetch_top100("not-a-genre")
|
||||
|
||||
def test_5xx_retries_and_raises_unreachable(self):
|
||||
traxsource._cache.clear()
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.get.return_value = _mock_response("", 503)
|
||||
with patch("core.traxsource._session", return_value=mock_sess), \
|
||||
patch("core.traxsource.time.sleep"):
|
||||
with pytest.raises(traxsource.TraxsourceUnreachableError):
|
||||
traxsource.fetch_top100("tech-house")
|
||||
# 3 tentativi
|
||||
assert mock_sess.get.call_count == 3
|
||||
|
||||
def test_cache_hit_within_ttl(self, genre_html, top100_html):
|
||||
traxsource._cache.clear()
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.get.side_effect = [
|
||||
_mock_response(genre_html, 200),
|
||||
_mock_response(top100_html, 200),
|
||||
]
|
||||
with patch("core.traxsource._session", return_value=mock_sess):
|
||||
traxsource.fetch_top100("tech-house")
|
||||
traxsource.fetch_top100("tech-house")
|
||||
# 2 chiamate al primo fetch (genre + top100), 0 al secondo
|
||||
assert mock_sess.get.call_count == 2
|
||||
|
||||
def test_force_refresh_bypasses_cache(self, genre_html, top100_html):
|
||||
traxsource._cache.clear()
|
||||
mock_sess = MagicMock()
|
||||
# 4 risposte (2 fetch x 2 richieste ciascuno)
|
||||
mock_sess.get.side_effect = [
|
||||
_mock_response(genre_html, 200),
|
||||
_mock_response(top100_html, 200),
|
||||
_mock_response(genre_html, 200),
|
||||
_mock_response(top100_html, 200),
|
||||
]
|
||||
with patch("core.traxsource._session", return_value=mock_sess):
|
||||
traxsource.fetch_top100("tech-house")
|
||||
traxsource.fetch_top100("tech-house", force_refresh=True)
|
||||
assert mock_sess.get.call_count == 4
|
||||
+78
-13
@@ -33,6 +33,10 @@
|
||||
<span class="nav-icon">🎧</span>
|
||||
<span>Beatport</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="traxsource" data-feature="audio">
|
||||
<span class="nav-icon">▲</span>
|
||||
<span>Traxsource</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="spotify" data-feature="audio">
|
||||
<span class="nav-icon">🟢</span>
|
||||
<span>Spotify</span>
|
||||
@@ -896,10 +900,11 @@
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo (Mix)</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-pos sortable" data-sort="position">#</th>
|
||||
<th class="sortable" data-sort="artists">Artista</th>
|
||||
<th class="sortable" data-sort="title">Titolo (Mix)</th>
|
||||
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
|
||||
<th class="col-date sortable" data-sort="release_date">Data</th>
|
||||
<th class="col-state">Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -923,6 +928,64 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: TRAXSOURCE ===== -->
|
||||
<section class="view" id="view-traxsource">
|
||||
<header class="hero hero-purple">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow purple">TRAXSOURCE CHARTS</div>
|
||||
<h1 class="hero-title">Top 100 Traxsource per genere</h1>
|
||||
<p class="hero-subtitle">La classifica mensile ufficiale. Scarica in un click con tag ID3 completi (label, cover art, genere).</p>
|
||||
</div>
|
||||
<div class="hero-deco">▲</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Genere</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<select id="traxsource-genre" class="input pill"></select>
|
||||
<button class="btn btn-primary pill" id="traxsource-load-btn">
|
||||
<span class="ico">↓</span> Carica Top 100
|
||||
</button>
|
||||
<span class="hint-inline">Shift+click per bypassare la cache</span>
|
||||
</div>
|
||||
<div id="traxsource-status" class="beatport-status"></div>
|
||||
<div id="traxsource-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card">
|
||||
<div class="beatport-table-wrap">
|
||||
<table id="traxsource-table" class="beatport-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="traxsource-select-all" /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos sortable" data-sort="position">#</th>
|
||||
<th class="sortable" data-sort="artists">Artista</th>
|
||||
<th class="sortable" data-sort="title">Titolo (Mix)</th>
|
||||
<th class="sortable" data-sort="label">Label</th>
|
||||
<th class="col-state">Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="traxsource-toolbar" hidden>
|
||||
<span class="counter" id="traxsource-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="traxsource-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="traxsource-stop-btn" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="traxsource-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: SPOTIFY SEARCH ===== -->
|
||||
<section class="view" id="view-spotify">
|
||||
<header class="hero hero-indigo">
|
||||
@@ -957,11 +1020,12 @@
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo</th>
|
||||
<th>Album</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-pos sortable" data-sort="position">#</th>
|
||||
<th class="sortable" data-sort="artists">Artista</th>
|
||||
<th class="sortable" data-sort="name">Titolo</th>
|
||||
<th class="sortable" data-sort="album">Album</th>
|
||||
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
|
||||
<th class="col-date sortable" data-sort="release_date">Data</th>
|
||||
<th class="col-state"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1015,10 +1079,11 @@
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Titolo video</th>
|
||||
<th>Canale</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-pos sortable" data-sort="position">#</th>
|
||||
<th class="sortable" data-sort="title">Titolo video</th>
|
||||
<th class="sortable" data-sort="channel">Canale</th>
|
||||
<th class="col-dur sortable" data-sort="duration_sec">Durata</th>
|
||||
<th class="col-date sortable" data-sort="release_date">Data</th>
|
||||
<th class="col-state"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
+473
-18
@@ -400,6 +400,7 @@ const logEls = {
|
||||
spotify: () => $("#spotify-log"),
|
||||
youtube: () => $("#youtube-log"),
|
||||
convert: () => $("#convert-log"),
|
||||
traxsource: () => $("#traxsource-log"),
|
||||
};
|
||||
|
||||
function classifyLog(msg) {
|
||||
@@ -512,6 +513,9 @@ async function init() {
|
||||
await SpotifyUI.init();
|
||||
await YoutubeUI.init();
|
||||
|
||||
// Traxsource charts tab
|
||||
await TraxsourceUI.init();
|
||||
|
||||
// WAV -> MP3 converter tab
|
||||
await ConvertUI.init();
|
||||
}
|
||||
@@ -1183,6 +1187,61 @@ $("#quotaUpgradeBtn")?.addEventListener("click", async () => {
|
||||
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Helper di ordinamento condivisi tra BeatportUI, SpotifyUI, YoutubeUI
|
||||
// ============================================================
|
||||
function _sortPairs(tracks, existing, col, dir) {
|
||||
const pairs = tracks.map((t, i) => ({ t, e: existing[i] || false, origIdx: i }));
|
||||
const dirMul = dir === "desc" ? -1 : 1;
|
||||
pairs.sort((a, b) => {
|
||||
let av = a.t[col], bv = b.t[col];
|
||||
if (col === "position") {
|
||||
// Per Beatport t.position esiste (rank ufficiale); per Spotify/YouTube
|
||||
// torniamo l'ordine originale della risposta API.
|
||||
av = (typeof av === "number") ? av : a.origIdx;
|
||||
bv = (typeof bv === "number") ? bv : b.origIdx;
|
||||
}
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
return (av - bv) * dirMul;
|
||||
}
|
||||
av = String(av == null ? "" : av).toLowerCase();
|
||||
bv = String(bv == null ? "" : bv).toLowerCase();
|
||||
// Empty strings sempre in fondo indipendentemente da direzione
|
||||
if (av === "" && bv !== "") return 1;
|
||||
if (bv === "" && av !== "") return -1;
|
||||
if (av < bv) return -1 * dirMul;
|
||||
if (av > bv) return 1 * dirMul;
|
||||
return 0;
|
||||
});
|
||||
return {
|
||||
tracks: pairs.map(p => p.t),
|
||||
existing: pairs.map(p => p.e),
|
||||
origIndexes: pairs.map(p => p.origIdx),
|
||||
};
|
||||
}
|
||||
|
||||
function _bindSortableHeaders(tableSelector, onSort) {
|
||||
document.querySelectorAll(`${tableSelector} th.sortable`).forEach(th => {
|
||||
th.style.cursor = "pointer";
|
||||
th.style.userSelect = "none";
|
||||
th.addEventListener("click", () => onSort(th.dataset.sort));
|
||||
});
|
||||
}
|
||||
|
||||
function _updateSortArrows(tableSelector, sortCol, sortDir) {
|
||||
document.querySelectorAll(`${tableSelector} th.sortable`).forEach(th => {
|
||||
const key = th.dataset.sort;
|
||||
// Usa un <span class="sort-arrow"> per non toccare il testo principale
|
||||
let arrow = th.querySelector(".sort-arrow");
|
||||
if (!arrow) {
|
||||
arrow = document.createElement("span");
|
||||
arrow.className = "sort-arrow";
|
||||
th.appendChild(arrow);
|
||||
}
|
||||
arrow.textContent = key === sortCol ? (sortDir === "asc" ? " ▲" : " ▼") : "";
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BEATPORT tab — carica Top 100 per genere, seleziona tracce, scarica
|
||||
// (il download riusa start_tracks_download, quindi passa dagli stessi
|
||||
@@ -1195,6 +1254,8 @@ const BeatportUI = (function () {
|
||||
let currentGenreName = "";
|
||||
let currentGenreSlug = "";
|
||||
let downloading = false;
|
||||
let sortCol = null;
|
||||
let sortDir = "asc";
|
||||
|
||||
function fmtDur(sec) {
|
||||
const n = Number(sec) || 0;
|
||||
@@ -1287,16 +1348,28 @@ const BeatportUI = (function () {
|
||||
const tbody = table.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
currentTracks.forEach((t, i) => {
|
||||
let displayTracks = currentTracks;
|
||||
let displayExisting = existing;
|
||||
let origIndexes = currentTracks.map((_, i) => i);
|
||||
if (sortCol) {
|
||||
const s = _sortPairs(currentTracks, existing, sortCol, sortDir);
|
||||
displayTracks = s.tracks;
|
||||
displayExisting = s.existing;
|
||||
origIndexes = s.origIndexes;
|
||||
}
|
||||
|
||||
displayTracks.forEach((t, i) => {
|
||||
const origIdx = origIndexes[i];
|
||||
const alreadyDl = displayExisting[i];
|
||||
const tr = document.createElement("tr");
|
||||
if (existing[i]) tr.classList.add("already-downloaded");
|
||||
if (alreadyDl) tr.classList.add("already-downloaded");
|
||||
|
||||
const tdCheck = document.createElement("td");
|
||||
tdCheck.className = "col-check";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.dataset.idx = String(i);
|
||||
cb.checked = !existing[i];
|
||||
cb.dataset.idx = String(origIdx);
|
||||
cb.checked = !alreadyDl;
|
||||
cb.addEventListener("change", updateSelectionCount);
|
||||
tdCheck.appendChild(cb);
|
||||
tr.appendChild(tdCheck);
|
||||
@@ -1316,7 +1389,7 @@ const BeatportUI = (function () {
|
||||
|
||||
const tdPos = document.createElement("td");
|
||||
tdPos.className = "col-pos";
|
||||
tdPos.textContent = String(t.position || (i + 1));
|
||||
tdPos.textContent = String(t.position || (origIdx + 1));
|
||||
tr.appendChild(tdPos);
|
||||
|
||||
const tdArtist = document.createElement("td");
|
||||
@@ -1332,20 +1405,36 @@ const BeatportUI = (function () {
|
||||
tdDur.textContent = fmtDur(t.duration_sec);
|
||||
tr.appendChild(tdDur);
|
||||
|
||||
const tdDate = document.createElement("td");
|
||||
tdDate.className = "col-date";
|
||||
tdDate.textContent = t.release_date || "";
|
||||
tr.appendChild(tdDate);
|
||||
|
||||
const tdState = document.createElement("td");
|
||||
tdState.className = "col-state";
|
||||
if (existing[i]) tdState.textContent = "✓ già scaricato";
|
||||
if (alreadyDl) tdState.textContent = "✓ già scaricato";
|
||||
tr.appendChild(tdState);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
_updateSortArrows("#beatport-table", sortCol, sortDir);
|
||||
table.hidden = false;
|
||||
$("#beatport-toolbar").hidden = false;
|
||||
updateOutputInfo();
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function handleSort(col) {
|
||||
if (sortCol === col) {
|
||||
sortDir = sortDir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortCol = col;
|
||||
sortDir = "asc";
|
||||
}
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
const boxes = $$("#beatport-table tbody input[type=checkbox]");
|
||||
const total = boxes.length;
|
||||
@@ -1462,6 +1551,7 @@ const BeatportUI = (function () {
|
||||
$("#beatport-download-btn").addEventListener("click", startDownload);
|
||||
$("#beatport-stop-btn").addEventListener("click", stopDownload);
|
||||
$("#beatport-select-all").addEventListener("change", (e) => toggleAll(e.target.checked));
|
||||
_bindSortableHeaders("#beatport-table", handleSort);
|
||||
|
||||
// Aggancia agli event listener esistenti del canale "download" senza rompere
|
||||
// il comportamento della tab Scarica (wrapping additivo).
|
||||
@@ -1492,7 +1582,7 @@ const BeatportUI = (function () {
|
||||
const SpotifyUI = (() => {
|
||||
// NOTE: local module state — named `mstate` to NOT shadow the outer
|
||||
// module-level `state` (which holds `state.config`).
|
||||
const mstate = { tracks: [], existing: [], downloading: false };
|
||||
const mstate = { tracks: [], existing: [], downloading: false, sortCol: null, sortDir: "asc" };
|
||||
|
||||
function _escape(s) {
|
||||
const d = document.createElement("div");
|
||||
@@ -1530,31 +1620,55 @@ const SpotifyUI = (() => {
|
||||
const tbody = $("#spotify-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
mstate.tracks.forEach((t, i) => {
|
||||
const already = !!mstate.existing[i];
|
||||
|
||||
let displayTracks = mstate.tracks;
|
||||
let displayExisting = mstate.existing;
|
||||
let origIndexes = mstate.tracks.map((_, i) => i);
|
||||
if (mstate.sortCol) {
|
||||
const s = _sortPairs(mstate.tracks, mstate.existing, mstate.sortCol, mstate.sortDir);
|
||||
displayTracks = s.tracks;
|
||||
displayExisting = s.existing;
|
||||
origIndexes = s.origIndexes;
|
||||
}
|
||||
|
||||
displayTracks.forEach((t, i) => {
|
||||
const origIdx = origIndexes[i];
|
||||
const already = !!displayExisting[i];
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.idx = String(i);
|
||||
tr.dataset.idx = String(origIdx);
|
||||
if (already) tr.classList.add("already-downloaded");
|
||||
const coverImg = t.image_url
|
||||
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||
: "";
|
||||
tr.innerHTML = `
|
||||
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
||||
<td class="col-check"><input type="checkbox" data-idx="${origIdx}" ${already ? "" : "checked"} /></td>
|
||||
<td class="col-cover">${coverImg}</td>
|
||||
<td class="col-pos">${i + 1}</td>
|
||||
<td class="col-pos">${origIdx + 1}</td>
|
||||
<td>${_escape(t.artists)}</td>
|
||||
<td>${_escape(t.name)}</td>
|
||||
<td>${_escape(t.album)}</td>
|
||||
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||
<td class="col-date">${_escape(t.release_date || "")}</td>
|
||||
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||
`;
|
||||
const cb = tr.querySelector("input[type=checkbox]");
|
||||
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
_updateSortArrows("#spotify-table", mstate.sortCol, mstate.sortDir);
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function handleSort(col) {
|
||||
if (mstate.sortCol === col) {
|
||||
mstate.sortDir = mstate.sortDir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
mstate.sortCol = col;
|
||||
mstate.sortDir = "asc";
|
||||
}
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#spotify-status");
|
||||
if (!el) return;
|
||||
@@ -1708,6 +1822,7 @@ const SpotifyUI = (() => {
|
||||
});
|
||||
$("#spotify-download-btn").addEventListener("click", startDownload);
|
||||
$("#spotify-stop-btn").addEventListener("click", stopDownload);
|
||||
_bindSortableHeaders("#spotify-table", handleSort);
|
||||
|
||||
// Wrap bridge handlers additivamente
|
||||
const origLog = bridgeHandlers["log"];
|
||||
@@ -1731,7 +1846,7 @@ const SpotifyUI = (() => {
|
||||
// YouTube search
|
||||
// =====================================================================
|
||||
const YoutubeUI = (() => {
|
||||
const mstate = { tracks: [], existing: [], downloading: false };
|
||||
const mstate = { tracks: [], existing: [], downloading: false, sortCol: null, sortDir: "asc" };
|
||||
|
||||
function _escape(s) {
|
||||
const d = document.createElement("div");
|
||||
@@ -1768,30 +1883,54 @@ const YoutubeUI = (() => {
|
||||
const tbody = $("#youtube-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
mstate.tracks.forEach((t, i) => {
|
||||
const already = !!mstate.existing[i];
|
||||
|
||||
let displayTracks = mstate.tracks;
|
||||
let displayExisting = mstate.existing;
|
||||
let origIndexes = mstate.tracks.map((_, i) => i);
|
||||
if (mstate.sortCol) {
|
||||
const s = _sortPairs(mstate.tracks, mstate.existing, mstate.sortCol, mstate.sortDir);
|
||||
displayTracks = s.tracks;
|
||||
displayExisting = s.existing;
|
||||
origIndexes = s.origIndexes;
|
||||
}
|
||||
|
||||
displayTracks.forEach((t, i) => {
|
||||
const origIdx = origIndexes[i];
|
||||
const already = !!displayExisting[i];
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.idx = String(i);
|
||||
tr.dataset.idx = String(origIdx);
|
||||
if (already) tr.classList.add("already-downloaded");
|
||||
const coverImg = t.image_url
|
||||
? `<img src="${_escape(t.image_url)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"/>`
|
||||
: "";
|
||||
tr.innerHTML = `
|
||||
<td class="col-check"><input type="checkbox" data-idx="${i}" ${already ? "" : "checked"} /></td>
|
||||
<td class="col-check"><input type="checkbox" data-idx="${origIdx}" ${already ? "" : "checked"} /></td>
|
||||
<td class="col-cover">${coverImg}</td>
|
||||
<td class="col-pos">${i + 1}</td>
|
||||
<td class="col-pos">${origIdx + 1}</td>
|
||||
<td>${_escape(t.title)}</td>
|
||||
<td>${_escape(t.channel)}</td>
|
||||
<td class="col-dur">${_fmtDuration(t.duration_sec)}</td>
|
||||
<td class="col-date">${_escape(t.release_date || "")}</td>
|
||||
<td class="col-state">${already ? "✓ già scaricato" : ""}</td>
|
||||
`;
|
||||
const cb = tr.querySelector("input[type=checkbox]");
|
||||
if (cb) cb.addEventListener("change", updateSelectionCount);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
_updateSortArrows("#youtube-table", mstate.sortCol, mstate.sortDir);
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function handleSort(col) {
|
||||
if (mstate.sortCol === col) {
|
||||
mstate.sortDir = mstate.sortDir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
mstate.sortCol = col;
|
||||
mstate.sortDir = "asc";
|
||||
}
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#youtube-status");
|
||||
if (!el) return;
|
||||
@@ -1934,6 +2073,7 @@ const YoutubeUI = (() => {
|
||||
});
|
||||
$("#youtube-download-btn").addEventListener("click", startDownload);
|
||||
$("#youtube-stop-btn").addEventListener("click", stopDownload);
|
||||
_bindSortableHeaders("#youtube-table", handleSort);
|
||||
|
||||
const origLog = bridgeHandlers["log"];
|
||||
bridgeHandlers["log"] = (payload) => {
|
||||
@@ -1952,6 +2092,321 @@ const YoutubeUI = (() => {
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// TRAXSOURCE tab — carica Top 100 per genere, seleziona tracce, scarica
|
||||
// Parallelo a BeatportUI (stessi canali "download:progress" /
|
||||
// "download:done" / log view="download"). Colonne: cover, #, artista,
|
||||
// titolo (mix), label, stato. No Durata / Data (non nel markup Traxsource).
|
||||
// ============================================================
|
||||
const TraxsourceUI = (function () {
|
||||
let genres = [];
|
||||
let currentTracks = [];
|
||||
let existing = [];
|
||||
let currentGenreName = "";
|
||||
let currentGenreSlug = "";
|
||||
let downloading = false;
|
||||
let sortCol = null;
|
||||
let sortDir = "asc";
|
||||
|
||||
function safeGenreFolder(name) {
|
||||
return String(name || "").replace(/[\/\\]/g, "_").trim();
|
||||
}
|
||||
|
||||
function populateSelect() {
|
||||
const sel = $("#traxsource-genre");
|
||||
if (!sel) return;
|
||||
sel.innerHTML = "";
|
||||
for (const g of genres) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g.slug;
|
||||
opt.textContent = g.name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function updateOutputInfo() {
|
||||
const info = $("#traxsource-output-info");
|
||||
if (!info) return;
|
||||
const root = (state.config && state.config.output_dir) || "";
|
||||
if (!root) {
|
||||
info.textContent = "⚠ Imposta la cartella output in Impostazioni prima di scaricare";
|
||||
info.className = "beatport-output-info warn";
|
||||
return;
|
||||
}
|
||||
const folder = safeGenreFolder(currentGenreName);
|
||||
info.textContent = folder
|
||||
? `Destinazione: ${root}/Traxsource_${folder}`
|
||||
: `Destinazione: ${root}/Traxsource`;
|
||||
info.className = "beatport-output-info";
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
const el = $("#traxsource-status");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "beatport-status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
async function loadChart(forceRefresh) {
|
||||
const sel = $("#traxsource-genre");
|
||||
const slug = sel && sel.value;
|
||||
if (!slug) return;
|
||||
const genreName = (sel.selectedOptions[0] && sel.selectedOptions[0].textContent) || slug;
|
||||
currentGenreSlug = slug;
|
||||
currentGenreName = genreName;
|
||||
|
||||
setStatus(forceRefresh ? "Ricarico Top 100 (bypass cache)…" : "Caricamento Top 100…", "loading");
|
||||
$("#traxsource-table").hidden = true;
|
||||
$("#traxsource-toolbar").hidden = true;
|
||||
$("#traxsource-table").querySelector("tbody").innerHTML = "";
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.traxsource_fetch_chart(slug, !!forceRefresh);
|
||||
} catch (e) {
|
||||
setStatus("Errore: " + ((e && e.message) || e), "error");
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const code = res && res.error;
|
||||
let msg = (res && res.message) || "Errore sconosciuto";
|
||||
if (code === "invalid_genre") msg = "Genere non valido";
|
||||
else if (code === "unreachable") msg = "Traxsource non raggiungibile — riprova più tardi";
|
||||
else if (code === "parse") msg = "Impossibile leggere i dati (schema pagina cambiato?)";
|
||||
setStatus(msg, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
currentTracks = res.tracks || [];
|
||||
try {
|
||||
existing = await window.pywebview.api.traxsource_check_existing(currentTracks, currentGenreName);
|
||||
} catch (_e) {
|
||||
existing = currentTracks.map(() => false);
|
||||
}
|
||||
setStatus(`${currentTracks.length} brani caricati`, "ok");
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const table = $("#traxsource-table");
|
||||
const tbody = table.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
let displayTracks = currentTracks;
|
||||
let displayExisting = existing;
|
||||
let origIndexes = currentTracks.map((_, i) => i);
|
||||
if (sortCol) {
|
||||
const s = _sortPairs(currentTracks, existing, sortCol, sortDir);
|
||||
displayTracks = s.tracks;
|
||||
displayExisting = s.existing;
|
||||
origIndexes = s.origIndexes;
|
||||
}
|
||||
|
||||
displayTracks.forEach((t, i) => {
|
||||
const origIdx = origIndexes[i];
|
||||
const alreadyDl = displayExisting[i];
|
||||
const tr = document.createElement("tr");
|
||||
if (alreadyDl) tr.classList.add("already-downloaded");
|
||||
|
||||
const tdCheck = document.createElement("td");
|
||||
tdCheck.className = "col-check";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.dataset.idx = String(origIdx);
|
||||
cb.checked = !alreadyDl;
|
||||
cb.addEventListener("change", updateSelectionCount);
|
||||
tdCheck.appendChild(cb);
|
||||
tr.appendChild(tdCheck);
|
||||
|
||||
const tdCover = document.createElement("td");
|
||||
tdCover.className = "col-cover";
|
||||
if (t.image_url) {
|
||||
const img = document.createElement("img");
|
||||
img.src = t.image_url;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
img.referrerPolicy = "no-referrer";
|
||||
img.onerror = () => { img.style.visibility = "hidden"; };
|
||||
tdCover.appendChild(img);
|
||||
}
|
||||
tr.appendChild(tdCover);
|
||||
|
||||
const tdPos = document.createElement("td");
|
||||
tdPos.className = "col-pos";
|
||||
tdPos.textContent = String(t.position || (origIdx + 1));
|
||||
tr.appendChild(tdPos);
|
||||
|
||||
const tdArtist = document.createElement("td");
|
||||
tdArtist.textContent = t.artists || "";
|
||||
tr.appendChild(tdArtist);
|
||||
|
||||
const tdTitle = document.createElement("td");
|
||||
tdTitle.textContent = t.mix ? `${t.title} (${t.mix})` : (t.title || "");
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
const tdLabel = document.createElement("td");
|
||||
tdLabel.textContent = t.label || "";
|
||||
tr.appendChild(tdLabel);
|
||||
|
||||
const tdState = document.createElement("td");
|
||||
tdState.className = "col-state";
|
||||
if (alreadyDl) tdState.textContent = "✓ già scaricato";
|
||||
tr.appendChild(tdState);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
_updateSortArrows("#traxsource-table", sortCol, sortDir);
|
||||
table.hidden = false;
|
||||
$("#traxsource-toolbar").hidden = false;
|
||||
updateOutputInfo();
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function handleSort(col) {
|
||||
if (sortCol === col) {
|
||||
sortDir = sortDir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortCol = col;
|
||||
sortDir = "asc";
|
||||
}
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function updateSelectionCount() {
|
||||
const boxes = $$("#traxsource-table tbody input[type=checkbox]");
|
||||
const total = boxes.length;
|
||||
const checked = boxes.filter((b) => b.checked).length;
|
||||
$("#traxsource-selected-count").textContent = `${checked}/${total} selezionati`;
|
||||
const dlBtn = $("#traxsource-download-btn");
|
||||
if (dlBtn) dlBtn.disabled = downloading || checked === 0;
|
||||
|
||||
const master = $("#traxsource-select-all");
|
||||
if (master) {
|
||||
if (total === 0) { master.checked = false; master.indeterminate = false; }
|
||||
else if (checked === 0) { master.checked = false; master.indeterminate = false; }
|
||||
else if (checked === total) { master.checked = true; master.indeterminate = false; }
|
||||
else { master.checked = false; master.indeterminate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll(checked) {
|
||||
$$("#traxsource-table tbody input[type=checkbox]").forEach((b) => { b.checked = checked; });
|
||||
updateSelectionCount();
|
||||
}
|
||||
|
||||
function appendTraxsourceLog(msg) {
|
||||
const el = $("#traxsource-log");
|
||||
if (!el) return;
|
||||
const cls = classifyLog(msg);
|
||||
const line = document.createElement("div");
|
||||
if (cls) line.className = cls;
|
||||
line.textContent = msg;
|
||||
el.appendChild(line);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const boxes = $$("#traxsource-table tbody input[type=checkbox]");
|
||||
const selected = [];
|
||||
boxes.forEach((b) => {
|
||||
if (b.checked) {
|
||||
const idx = parseInt(b.dataset.idx, 10);
|
||||
if (!isNaN(idx) && currentTracks[idx]) selected.push(currentTracks[idx]);
|
||||
}
|
||||
});
|
||||
if (!selected.length) {
|
||||
toast("Seleziona almeno un brano", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.config || !state.config.output_dir) {
|
||||
toast("Imposta la cartella output in Impostazioni", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
downloading = true;
|
||||
$("#traxsource-download-btn").disabled = true;
|
||||
$("#traxsource-stop-btn").hidden = false;
|
||||
$("#traxsource-stop-btn").disabled = false;
|
||||
$("#traxsource-log").innerHTML = "";
|
||||
appendTraxsourceLog(`[INFO] Avvio download di ${selected.length} brani da ${currentGenreName}…`);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await window.pywebview.api.traxsource_download_selected(selected, currentGenreName);
|
||||
} catch (e) {
|
||||
appendTraxsourceLog("[ERRORE] " + ((e && e.message) || e));
|
||||
finishDownload();
|
||||
return;
|
||||
}
|
||||
if (!res || !res.ok) {
|
||||
const errMsg = (res && (res.error || res.message)) || "Impossibile avviare il download";
|
||||
appendTraxsourceLog("[ERRORE] " + errMsg);
|
||||
if (!handleGateBlock(res || {})) toast(errMsg, "error");
|
||||
finishDownload();
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDownload() {
|
||||
try {
|
||||
await window.pywebview.api.stop_download();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function finishDownload() {
|
||||
downloading = false;
|
||||
$("#traxsource-stop-btn").hidden = true;
|
||||
$("#traxsource-stop-btn").disabled = true;
|
||||
updateSelectionCount();
|
||||
if (currentTracks.length) {
|
||||
window.pywebview.api.traxsource_check_existing(currentTracks, currentGenreName)
|
||||
.then((res) => { existing = res || existing; renderTable(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
genres = await window.pywebview.api.traxsource_genres();
|
||||
} catch (e) {
|
||||
console.error("Traxsource: impossibile caricare i generi", e);
|
||||
genres = [];
|
||||
}
|
||||
populateSelect();
|
||||
|
||||
const last = (state.config && state.config.traxsource_last_genre) || "";
|
||||
const sel = $("#traxsource-genre");
|
||||
if (sel && last && genres.some((g) => g.slug === last)) {
|
||||
sel.value = last;
|
||||
}
|
||||
updateOutputInfo();
|
||||
|
||||
$("#traxsource-load-btn").addEventListener("click", (e) => loadChart(e.shiftKey));
|
||||
$("#traxsource-download-btn").addEventListener("click", startDownload);
|
||||
$("#traxsource-stop-btn").addEventListener("click", stopDownload);
|
||||
$("#traxsource-select-all").addEventListener("change", (e) => toggleAll(e.target.checked));
|
||||
_bindSortableHeaders("#traxsource-table", handleSort);
|
||||
|
||||
// Wrapping additivo sui bridgeHandlers (canale "download" condiviso)
|
||||
const origProgress = bridgeHandlers["download:progress"];
|
||||
bridgeHandlers["download:progress"] = (p) => {
|
||||
if (origProgress) origProgress(p);
|
||||
};
|
||||
const origDone = bridgeHandlers["download:done"];
|
||||
bridgeHandlers["download:done"] = (p) => {
|
||||
if (origDone) origDone(p);
|
||||
if (downloading) finishDownload();
|
||||
};
|
||||
const origLog = bridgeHandlers["log"];
|
||||
bridgeHandlers["log"] = ({ view, msg }) => {
|
||||
if (origLog) origLog({ view, msg });
|
||||
if (downloading && view === "download") appendTraxsourceLog(msg);
|
||||
};
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
// =====================================================================
|
||||
// Converti (WAV -> MP3)
|
||||
// =====================================================================
|
||||
|
||||
Reference in new issue
Block a user