Compare commits

...
5 Commits
Author SHA1 Message Date
luciano 8432b2a592 polish: Charts UI + sidebar categorie + prune Traxsource generi (v1.12.0)
- Charts CSS: .charts-filters[hidden] display:none !important, altrimenti
  la regola display:flex sovrascriveva l'attributo hidden e tutti i pannelli
  filtro delle 4 sorgenti erano visibili contemporaneamente
- Sidebar riorganizzata in 4 categorie (Scarica/Organizza/Altro/Sistema)
  con .nav-section-label uppercase piccole tra i gruppi
- Traxsource: rimossi 10 generi per cui il sito ha rimosso la playlist
  canonica "Top 100 <genre> of <month>" (broken-beat-nu-jazz, classic-house,
  drum-and-bass, electro-house, electronica, leftfield, lounge-chill-out,
  pop-dance, r-and-b-hip-hop, world). Dropdown ora mostra solo 13 generi
  con Top 100 attiva verificata
2026-09-01 21:08:04 +02:00
luciano 39e843dc69 ui: tab Charts con tabella streaming + integrazione coda Scarica
Nuova tab "Charts" (nav-item con icona grafico) che espone quattro
sorgenti di classifiche:
- Del momento (iTunes): dropdown paese Italia/USA/UK/Mondo
- Spotify Charts: dropdown 6 playlist editoriali (Top 50 e Viral 50
  per Global/Italy/USA/UK)
- M2O Chart: chart dance italiana settimanale (nessun filtro)
- Storiche per genere (Last.fm): combinazione decade x genere

Tabella streaming stile catalog/dedup con # / artista / titolo /
album / popolarità, "Seleziona tutti" e footer sticky col contatore
selezionati + bottone "Aggiungi N alla coda Scarica".

Il bottone invoca api.charts_add_to_queue: il backend emette l'evento
charts:add_to_queue che la UI intercetta popolando state.loaded come
tracklist (kind: "tracks") - stesso pattern usato da "Carica lista" -
poi commuta view su Scarica, mostra il badge "· N tracce" e appende
al log Scarica. L'utente preme "Avvia Download" e la coda parte.

CSS charts-* riusa i pattern catalog-*/dedup-* (scroll 55vh, footer
sticky, badge status).
2026-08-28 13:41:13 +02:00
luciano 29c177ff69 bridge: metodi Api per tab Charts + bump v1.12.0
- api.charts_fetch(payload): dispatcher unico su iTunes/Last.fm/Spotify/
  M2O, persiste la sorgente/params scelti in config, ritorna
  {ok, source, entries}
- api.charts_add_to_queue(entries): normalizza le entry selezionate in
  tracklist "Artista - Titolo" ed emette l'evento `charts:add_to_queue`
  che la UI aggancia per popolare la tab Scarica
- config.DEFAULTS: aggiunte chiavi charts_last_source, charts_itunes_
  country, charts_lastfm_decade/genre, charts_spotify_playlist,
  lastfm_api_key
- VERSION -> v1.12.0
2026-08-28 13:38:25 +02:00
luciano 75e3da5f82 charts: modulo iTunes + Last.fm + Spotify + M2O con cache SQLite
Nuovo core/charts.py con quattro sorgenti classifiche:
- fetch_itunes(country): Top 100 iTunes RSS per paese (it/us/gb/ww)
- fetch_lastfm(decade, genre): top tracks Last.fm per tag combinato
  (es. "90s dance", "italo disco") - richiede API key (demo hardcoded,
  sovrascrivibile via config `lastfm_api_key`)
- fetch_spotify(playlist_key): brani da 6 playlist chart editoriali
  Spotify (Top 50 / Viral 50 per Global/Italy/USA/UK) via Client
  Credentials gia' configurate
- fetch_m2o(): scraping HTML della chart dance italiana settimanale

Tutte con cache SQLite (~/Library/Application Support/MusicTools/
charts_cache.db) TTL 6h, key per sorgente+params, invalidazione via
force=True.

26 test coprono parsing, cache hit/miss, TTL scaduta (freezegun),
gestione errori HTTP/JSON e assenza credenziali.
2026-08-28 13:37:26 +02:00
luciano 29b9753aa3 fix: mostra causa reale dietro "yt-dlp exit 1" (v1.11.3)
In download_playlist il codice legge stdout/stderr merged per parsare
il progress ma poi butta via tutte le righe. Se return_code != 0
l'utente vedeva solo "error: yt-dlp exit 1" senza sapere il perche'
(video bloccato? age-gate? 403? region-lock?).

Fix: buffer ring (10 righe non-progress) durante il download. Se
return_code != 0, estrae la riga con ERROR/error e la accoda al
messaggio -> "error: yt-dlp exit 1 — ERROR: [youtube] Video unavailable".
Massimo 220 char per non intasare il log.

Applicato solo al pattern download_playlist per ora (quello che usa il
utente sul batch Spotify). Direct URL e batch tags idem se emergono.
2026-08-28 10:08:52 +02:00
9 changed files with 1702 additions and 26 deletions

No files matched your search

+106
View File
@@ -49,6 +49,7 @@ from core.upgrader import (
from core import dedup
from core import catalog
from core import flatten
from core import charts
SPOTIFY_GUIDE_TEXT = """\
@@ -2263,3 +2264,108 @@ class Api:
"n_matched": n_matched,
"n_unmatched": n_unmatched,
})
# ================================================================
# CHARTS — classifiche musicali (iTunes / Spotify / M2O / Last.fm)
# ================================================================
def charts_fetch(self, payload: dict) -> dict:
"""Ritorna le entry di una chart per la sorgente/params richiesti.
Payload:
{
source: "itunes" | "spotify" | "m2o" | "lastfm",
country?: "it"|"us"|"gb"|"ww", # solo itunes
playlist_key?: str, # solo spotify (chiave _SPOTIFY_CHARTS)
decade?: str, # solo lastfm ("" | "70s"|"80s"|...)
genre?: str, # solo lastfm ("" | pop|rock|dance|...)
force?: bool, # bypass cache
}
Persiste anche la scelta corrente in config.
"""
payload = payload or {}
source = (payload.get("source") or "itunes").strip().lower()
force = bool(payload.get("force", False))
# Persist scelta corrente (best-effort, non blocca la fetch)
try:
cfg = load_config()
cfg["charts_last_source"] = source
if source == "itunes":
cfg["charts_itunes_country"] = (
payload.get("country") or cfg.get("charts_itunes_country") or "it"
)
elif source == "lastfm":
cfg["charts_lastfm_decade"] = (
payload.get("decade") if payload.get("decade") is not None
else cfg.get("charts_lastfm_decade", "90s")
)
cfg["charts_lastfm_genre"] = (
payload.get("genre") if payload.get("genre") is not None
else cfg.get("charts_lastfm_genre", "dance")
)
elif source == "spotify":
cfg["charts_spotify_playlist"] = (
payload.get("playlist_key")
or cfg.get("charts_spotify_playlist")
or "top50_global"
)
save_config(cfg)
except Exception:
pass
try:
if source == "itunes":
country = (payload.get("country") or "it").strip().lower() or "it"
entries = charts.fetch_itunes(country, force=force)
elif source == "lastfm":
decade = payload.get("decade") or ""
genre = payload.get("genre") or ""
entries = charts.fetch_lastfm(decade, genre, force=force)
elif source == "spotify":
playlist_key = (payload.get("playlist_key") or "top50_global").strip()
entries = charts.fetch_spotify(playlist_key, force=force)
elif source == "m2o":
entries = charts.fetch_m2o(force=force)
else:
return {"ok": False, "error": f"Sorgente sconosciuta: {source}"}
except Exception as e:
self._log("charts", f"[ERRORE] fetch {source}: {e}")
return {"ok": False, "error": str(e)}
self._log("charts", f"[INFO] {source}: {len(entries)} brani ricevuti")
return {"ok": True, "source": source, "entries": entries}
def charts_add_to_queue(self, entries: list) -> dict:
"""Riceve una lista di entry `{artist, title}` selezionate dalla UI
e le converte in una tracklist compatibile con la tab Scarica.
Emette l'evento `charts:add_to_queue` col testo formattato
(`Artista - Titolo` una per riga) e la lista strutturata. La UI
popola l'input della tab Scarica e commuta view.
"""
if not isinstance(entries, list):
return {"ok": False, "error": "entries deve essere una lista"}
tracks = []
lines = []
for e in entries:
if not isinstance(e, dict):
continue
artist = (e.get("artist") or "").strip()
title = (e.get("title") or "").strip()
if not title:
continue
tracks.append({"artist": artist, "name": title})
if artist:
lines.append(f"{artist} - {title}")
else:
lines.append(title)
if not tracks:
return {"ok": False, "error": "Nessun brano valido selezionato"}
text = "\n".join(lines)
payload = {"text": text, "count": len(tracks), "tracks": tracks}
self._emit("charts:add_to_queue", payload)
self._log("charts", f"[INFO] {len(tracks)} brani inviati alla coda Scarica")
return {"ok": True, "count": len(tracks)}
+437
View File
@@ -0,0 +1,437 @@
"""Fetch classifiche musicali da varie sorgenti + cache SQLite.
Sorgenti supportate:
- **iTunes RSS** (`fetch_itunes`) — Top 100 "most-played" per paese (it/us/gb/ww).
Endpoint pubblico, no auth. Response JSON.
- **Last.fm** (`fetch_lastfm`) — Top tracks per tag (combinazione decade
+ genere musicale, es. "90s dance"). Richiede API key: c'e' una demo
hardcoded, l'utente puo' generarne una propria e sovrascriverla via
config (`lastfm_api_key`). https://www.last.fm/api/account/create
- **Spotify** (`fetch_spotify`) — Legge playlist chart editoriali di Spotify
(Top 50 / Viral 50 per paese) via `playlists/<id>/tracks`. Riusa le
credenziali Client Credentials gia' configurate (`client_id`/
`client_secret`). Se mancano ritorna [].
- **M2O Chart** (`fetch_m2o`) — Scraping HTML della chart dance italiana
settimanale di M2O (https://www.m2o.it/classifiche/m2o-chart/).
Tutte le funzioni ritornano una lista di dict con keys:
{rank, artist, title, album, image_url, popularity}
Cache SQLite in `~/Library/Application Support/MusicTools/charts_cache.db`
(dev: nella dir del progetto). TTL 6h. `force=True` bypassa la cache.
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Optional
import requests
# ---- Last.fm ----
# Demo key: sostituibile via config `lastfm_api_key`. Se questa non funziona
# piu', registrane una nuova su https://www.last.fm/api/account/create.
_LASTFM_KEY = "d060bf4a1907efe5b1e4b8b0f9a4a86e"
_LASTFM_URL = "https://ws.audioscrobbler.com/2.0/"
# ---- iTunes RSS ----
_ITUNES_URL_TPL = (
"https://rss.applemarketingtools.com/api/v2/{country}/music/"
"most-played/100/songs.json"
)
# ---- Spotify chart playlists ----
# Playlist ID editoriali di Spotify. Chiave interna -> (playlist_id, nome UI).
_SPOTIFY_CHARTS = {
"top50_global": ("37i9dQZEVXbMDoHDwVN2tF", "Top 50 Global"),
"top50_italy": ("37i9dQZEVXbIQnj7RRhdSX", "Top 50 Italy"),
"top50_usa": ("37i9dQZEVXbLRQDuF5jeBp", "Top 50 USA"),
"top50_uk": ("37i9dQZEVXbLnolsZ8PSNw", "Top 50 UK"),
"viral50_global": ("37i9dQZEVXbLiRSasKsNU9", "Viral 50 Global"),
"viral50_italy": ("37i9dQZEVXbNFJfN1Vw8d9", "Viral 50 Italy"),
}
# ---- M2O Chart ----
_M2O_URL = "https://www.m2o.it/classifiche/m2o-chart/"
# ---- Cache ----
_CACHE_TTL_SEC = 6 * 3600 # 6h
# ------------------------------------------------------------------
# Cache SQLite
# ------------------------------------------------------------------
def _cache_db_path() -> Path:
from core.config import _get_config_dir
return _get_config_dir() / "charts_cache.db"
def _init_db(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS charts(
key TEXT PRIMARY KEY,
payload TEXT,
cached_at REAL
);
"""
)
def _get_cached(conn: sqlite3.Connection, key: str) -> Optional[list]:
row = conn.execute(
"SELECT payload, cached_at FROM charts WHERE key=?", (key,)
).fetchone()
if not row:
return None
payload, cached_at = row
if time.time() - float(cached_at or 0) > _CACHE_TTL_SEC:
return None
try:
data = json.loads(payload)
return data if isinstance(data, list) else None
except Exception:
return None
def _put_cache(conn: sqlite3.Connection, key: str, data: list) -> None:
try:
conn.execute(
"INSERT OR REPLACE INTO charts(key, payload, cached_at) VALUES (?, ?, ?)",
(key, json.dumps(data, ensure_ascii=False), time.time()),
)
conn.commit()
except sqlite3.Error:
# Cache best-effort: se scrivere fallisce non blocchiamo il fetch
pass
# ------------------------------------------------------------------
# Last.fm helpers
# ------------------------------------------------------------------
def _get_lastfm_key() -> str:
"""Ritorna la key Last.fm dalla config se settata, altrimenti la demo."""
try:
from core.config import load_config
k = (load_config().get("lastfm_api_key") or "").strip()
return k or _LASTFM_KEY
except Exception:
return _LASTFM_KEY
def _build_lastfm_tag(decade: str, genre: str) -> str:
"""Combina decade + genere in un tag Last.fm compatibile.
decade: '' | '70s' | '80s' | '90s' | '2000s' | '2010s'
genre: '' | 'pop' | 'rock' | 'dance' | ...
Esempi:
_build_lastfm_tag('90s', 'dance') -> '90s dance'
_build_lastfm_tag('', 'italo disco') -> 'italo disco'
_build_lastfm_tag('80s', '') -> '80s'
_build_lastfm_tag('', '') -> 'pop' (fallback)
"""
d = (decade or "").strip().lower()
g = (genre or "").strip().lower()
parts: list = []
if d:
parts.append(d)
if g:
parts.append(g)
return " ".join(parts) if parts else "pop"
# ------------------------------------------------------------------
# iTunes RSS
# ------------------------------------------------------------------
def fetch_itunes(country: str = "it", force: bool = False) -> list:
"""Ritorna la top 100 iTunes "most-played" per il paese.
country: "it" | "us" | "gb" | "ww"
Ogni entry:
{rank, artist, title, album, image_url, popularity=None}
Cache 6h. `force=True` bypassa. In caso di errore HTTP/JSON ritorna [].
"""
country = (country or "it").lower().strip() or "it"
key = f"itunes:{country}"
conn = sqlite3.connect(_cache_db_path())
try:
_init_db(conn)
if not force:
cached = _get_cached(conn, key)
if cached is not None:
return cached
url = _ITUNES_URL_TPL.format(country=country)
try:
resp = requests.get(url, timeout=15)
resp.raise_for_status()
data = resp.json()
except Exception:
return []
entries: list = []
for i, item in enumerate(
(data.get("feed", {}) or {}).get("results", []) or [], start=1
):
entries.append({
"rank": i,
"artist": (item.get("artistName") or "").strip(),
"title": (item.get("name") or "").strip(),
"album": (item.get("collectionName") or "").strip(),
"image_url": (item.get("artworkUrl100") or "").strip(),
"popularity": None,
})
_put_cache(conn, key, entries)
return entries
finally:
conn.close()
# ------------------------------------------------------------------
# Last.fm
# ------------------------------------------------------------------
def fetch_lastfm(decade: str, genre: str, force: bool = False) -> list:
"""Ritorna top tracks per il tag Last.fm costruito da decade+genere.
Esempi:
fetch_lastfm('90s', 'dance') -> tag '90s dance'
fetch_lastfm('', 'italo disco') -> tag 'italo disco'
Cache 6h. In caso di errore o `tracks.track` mancante ritorna [].
"""
tag = _build_lastfm_tag(decade, genre)
key = f"lastfm:{tag}"
conn = sqlite3.connect(_cache_db_path())
try:
_init_db(conn)
if not force:
cached = _get_cached(conn, key)
if cached is not None:
return cached
try:
resp = requests.get(
_LASTFM_URL,
params={
"method": "tag.gettoptracks",
"tag": tag,
"api_key": _get_lastfm_key(),
"format": "json",
"limit": 50,
},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
except Exception:
return []
entries: list = []
tracks = (data.get("tracks", {}) or {}).get("track", []) or []
for i, item in enumerate(tracks, start=1):
artist_obj = item.get("artist") or {}
if isinstance(artist_obj, dict):
artist_name = (artist_obj.get("name") or "").strip()
else:
artist_name = str(artist_obj).strip()
# Ultima immagine (di solito la piu' grande) se presente
image_url = ""
imgs = item.get("image") or []
if isinstance(imgs, list) and imgs:
last = imgs[-1]
if isinstance(last, dict):
image_url = (last.get("#text") or "").strip()
entries.append({
"rank": i,
"artist": artist_name,
"title": (item.get("name") or "").strip(),
"album": "",
"image_url": image_url,
"popularity": item.get("listeners"),
})
_put_cache(conn, key, entries)
return entries
finally:
conn.close()
# ------------------------------------------------------------------
# Spotify chart playlists
# ------------------------------------------------------------------
def _spotify_credentials() -> tuple:
"""Ritorna (client_id, client_secret) dalla config, o ('','') se assenti."""
try:
from core.config import load_config
cfg = load_config()
return (
(cfg.get("client_id") or "").strip(),
(cfg.get("client_secret") or "").strip(),
)
except Exception:
return ("", "")
def fetch_spotify(playlist_key: str, force: bool = False) -> list:
"""Ritorna i primi 50 brani di una chart playlist editoriale Spotify.
`playlist_key` deve essere una chiave di `_SPOTIFY_CHARTS`.
Richiede `client_id`/`client_secret` gia' configurate (le stesse
usate dalle altre tab). Se mancano o l'auth fallisce ritorna [].
"""
key = (playlist_key or "").strip()
if key not in _SPOTIFY_CHARTS:
return []
playlist_id, _label = _SPOTIFY_CHARTS[key]
cache_key = f"spotify:{key}"
conn = sqlite3.connect(_cache_db_path())
try:
_init_db(conn)
if not force:
cached = _get_cached(conn, cache_key)
if cached is not None:
return cached
client_id, client_secret = _spotify_credentials()
if not client_id or not client_secret:
return []
# Import qui per evitare import ciclici con core.config a livello modulo.
from core.spotify_client import get_access_token
try:
token = get_access_token(client_id, client_secret)
except Exception:
return []
try:
resp = requests.get(
f"https://api.spotify.com/v1/playlists/{playlist_id}/tracks",
headers={"Authorization": f"Bearer {token}"},
params={"limit": 50},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
except Exception:
return []
entries: list = []
items = data.get("items") or []
for i, item in enumerate(items, start=1):
track = (item or {}).get("track") or {}
if not track:
continue
artists = track.get("artists") or []
artist_names = [
(a.get("name") or "").strip()
for a in artists
if isinstance(a, dict) and a.get("name")
]
artist = ", ".join(artist_names)
album = ((track.get("album") or {}).get("name") or "").strip()
image_url = ""
imgs = ((track.get("album") or {}).get("images") or [])
if imgs and isinstance(imgs[0], dict):
image_url = (imgs[0].get("url") or "").strip()
entries.append({
"rank": i,
"artist": artist,
"title": (track.get("name") or "").strip(),
"album": album,
"image_url": image_url,
"popularity": track.get("popularity"),
})
_put_cache(conn, cache_key, entries)
return entries
finally:
conn.close()
# ------------------------------------------------------------------
# M2O Chart
# ------------------------------------------------------------------
def fetch_m2o(force: bool = False) -> list:
"""Scraping della chart dance italiana settimanale di M2O.
La pagina non ha API: parsiamo l'HTML con BeautifulSoup. Estraiamo
da ogni <article>:
- rank <- .position (int, altrimenti skip)
- title <- h3.title.song
- artist <- h4.title.author (lista virgola-separata, lasciata così)
- image_url <- figure img[src]
album e popularity sono sempre vuoti (non presenti nella pagina).
Cache 6h. Errori HTTP/parse -> [].
"""
cache_key = "m2o:chart"
conn = sqlite3.connect(_cache_db_path())
try:
_init_db(conn)
if not force:
cached = _get_cached(conn, cache_key)
if cached is not None:
return cached
try:
resp = requests.get(
_M2O_URL,
headers={"User-Agent": "Mozilla/5.0 (MusicTools charts)"},
timeout=15,
)
resp.raise_for_status()
html = resp.text
except Exception:
return []
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
except Exception:
return []
entries: list = []
for art in soup.find_all("article"):
pos_el = art.select_one(".position")
if pos_el is None:
continue
try:
rank = int((pos_el.get_text() or "").strip())
except (ValueError, TypeError):
continue
title_el = art.select_one("h3.title.song") or art.select_one("h3.song")
artist_el = art.select_one("h4.title.author") or art.select_one("h4.author")
title = title_el.get_text(strip=True) if title_el else ""
artist = artist_el.get_text(strip=True) if artist_el else ""
if not title and not artist:
continue
img_el = art.select_one("figure img")
image_url = ""
if img_el is not None:
image_url = (img_el.get("src") or img_el.get("data-src") or "").strip()
entries.append({
"rank": rank,
"artist": artist,
"title": title,
"album": "",
"image_url": image_url,
"popularity": None,
})
entries.sort(key=lambda e: e.get("rank", 9999))
_put_cache(conn, cache_key, entries)
return entries
finally:
conn.close()
+8 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.11.2"
VERSION = "v1.12.0"
APP_NAME = "MusicTools"
@@ -88,6 +88,13 @@ DEFAULTS = {
"catalog_last_target": "",
"catalog_recursive": True,
"acoustid_app_key": "", # opzionale: se vuoto usa default in core/acoustid.py
# ---- Charts (iTunes RSS + Last.fm + Spotify + M2O) ----
"charts_last_source": "itunes", # "itunes" | "spotify" | "m2o" | "lastfm"
"charts_itunes_country": "it", # "it" | "us" | "gb" | "ww"
"charts_lastfm_decade": "90s", # "" | "70s" | "80s" | "90s" | "2000s" | "2010s"
"charts_lastfm_genre": "dance", # "" | pop, rock, dance, ...
"charts_spotify_playlist": "top50_global", # chiave in charts._SPOTIFY_CHARTS
"lastfm_api_key": "", # opzionale: override della chiave demo
# ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto
+18 -1
View File
@@ -247,6 +247,7 @@ def download_playlist(
**subprocess_flags(),
)
last_lines: list = [] # buffer per capire l'errore se exit != 0
for line in _current_process.stdout:
if is_stopped():
_current_process.terminate()
@@ -254,6 +255,13 @@ def download_playlist(
progress_callback(i, total, query, "stopped", 0)
return
# Tieni le ultime 10 righe non-progress per il report d'errore
stripped = line.strip()
if stripped and not re.match(r"^\[download\]\s+\d", stripped):
last_lines.append(stripped)
if len(last_lines) > 10:
last_lines.pop(0)
# Parse progress da output yt-dlp
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
if pct_match and progress_callback:
@@ -278,8 +286,17 @@ def download_playlist(
if progress_callback:
progress_callback(i, total, query, "done", 100)
else:
# Estrai la vera causa da last_lines: cerca la riga ERROR di yt-dlp
err_hint = ""
for l in reversed(last_lines):
if "ERROR" in l or "error" in l:
err_hint = l[:220]
break
if not err_hint and last_lines:
err_hint = last_lines[-1][:220]
detail = f" — {err_hint}" if err_hint else ""
if progress_callback:
progress_callback(i, total, query, f"error: yt-dlp exit {return_code}", 0)
progress_callback(i, total, query, f"error: yt-dlp exit {return_code}{detail}", 0)
except Exception as e:
with _process_lock:
+4 -10
View File
@@ -14,30 +14,24 @@ 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).
# NOTA (ago 2026): Traxsource ha rimosso la "Top 100 <genre>" per 10 generi minori
# (broken-beat-nu-jazz, classic-house, drum-and-bass, electro-house, electronica,
# leftfield, lounge-chill-out, pop-dance, r-and-b-hip-hop, world). Rimossi dal
# dropdown finché non ripristinano la playlist canonica.
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"),
}
+434
View File
@@ -0,0 +1,434 @@
"""Test per core.charts — fetch iTunes/Last.fm/Spotify/M2O + cache SQLite.
Ogni test isola il DB cache in tmp_path via monkeypatch di `_cache_db_path`
e usa `responses`/`mock` per non toccare la rete.
"""
from __future__ import annotations
import time
from unittest import mock
import pytest
import responses
from freezegun import freeze_time
from core import charts
# ------------------------------------------------------------------
# Fixture: cache isolata per test
# ------------------------------------------------------------------
@pytest.fixture
def patched_cache(tmp_path, monkeypatch):
db = tmp_path / "charts_cache_test.db"
monkeypatch.setattr(charts, "_cache_db_path", lambda: db)
return db
# ==================================================================
# iTunes RSS
# ==================================================================
class TestFetchItunes:
_SAMPLE = {
"feed": {
"results": [
{
"name": "Song A",
"artistName": "Artist A",
"collectionName": "Album A",
"artworkUrl100": "https://cdn/a.jpg",
"url": "https://itunes/1",
},
{
"name": "Song B",
"artistName": "Artist B",
"collectionName": "Album B",
"artworkUrl100": "https://cdn/b.jpg",
"url": "https://itunes/2",
},
]
}
}
@responses.activate
def test_fetch_itunes_parses_response(self, patched_cache):
responses.add(
responses.GET,
charts._ITUNES_URL_TPL.format(country="it"),
json=self._SAMPLE,
status=200,
)
out = charts.fetch_itunes("it", force=True)
assert len(out) == 2
assert out[0] == {
"rank": 1,
"artist": "Artist A",
"title": "Song A",
"album": "Album A",
"image_url": "https://cdn/a.jpg",
"popularity": None,
}
assert out[1]["rank"] == 2
assert out[1]["artist"] == "Artist B"
@responses.activate
def test_fetch_itunes_uses_cache(self, patched_cache):
"""Seconda chiamata NON deve colpire la rete: il mock registra 1 call."""
responses.add(
responses.GET,
charts._ITUNES_URL_TPL.format(country="us"),
json=self._SAMPLE,
status=200,
)
first = charts.fetch_itunes("us", force=False)
second = charts.fetch_itunes("us", force=False)
assert first == second
# `responses` fallirebbe se ci fossero call non registrate;
# verifica esplicitamente che la seconda non ha creato una call in piu'
assert len(responses.calls) == 1
@responses.activate
def test_fetch_itunes_force_bypasses_cache(self, patched_cache):
responses.add(
responses.GET,
charts._ITUNES_URL_TPL.format(country="gb"),
json=self._SAMPLE,
status=200,
)
responses.add(
responses.GET,
charts._ITUNES_URL_TPL.format(country="gb"),
json=self._SAMPLE,
status=200,
)
charts.fetch_itunes("gb", force=False)
charts.fetch_itunes("gb", force=True)
assert len(responses.calls) == 2
@responses.activate
def test_fetch_itunes_returns_empty_on_error(self, patched_cache):
responses.add(
responses.GET,
charts._ITUNES_URL_TPL.format(country="ww"),
status=500,
)
assert charts.fetch_itunes("ww", force=True) == []
@responses.activate
def test_fetch_itunes_defaults_to_it(self, patched_cache):
responses.add(
responses.GET,
charts._ITUNES_URL_TPL.format(country="it"),
json={"feed": {"results": []}},
status=200,
)
assert charts.fetch_itunes("", force=True) == []
assert responses.calls[0].request.url.startswith(
"https://rss.applemarketingtools.com/api/v2/it/"
)
# ==================================================================
# Last.fm tag builder
# ==================================================================
class TestBuildLastfmTag:
def test_only_decade(self):
assert charts._build_lastfm_tag("80s", "") == "80s"
def test_only_genre(self):
assert charts._build_lastfm_tag("", "italo disco") == "italo disco"
def test_decade_and_genre(self):
assert charts._build_lastfm_tag("90s", "dance") == "90s dance"
def test_normalizes_case(self):
assert charts._build_lastfm_tag("90S", "DANCE") == "90s dance"
def test_fallback_empty(self):
assert charts._build_lastfm_tag("", "") == "pop"
assert charts._build_lastfm_tag(None, None) == "pop"
# ==================================================================
# Last.fm fetch
# ==================================================================
class TestFetchLastfm:
_SAMPLE = {
"tracks": {
"track": [
{
"name": "Blue (Da Ba Dee)",
"artist": {"name": "Eiffel 65"},
"listeners": "12345",
"image": [
{"#text": "small.jpg", "size": "small"},
{"#text": "large.jpg", "size": "large"},
],
},
{
"name": "Barbie Girl",
"artist": {"name": "Aqua"},
"listeners": "9999",
"image": [],
},
]
}
}
@responses.activate
def test_fetch_lastfm_parses_response(self, patched_cache):
responses.add(responses.GET, charts._LASTFM_URL, json=self._SAMPLE, status=200)
out = charts.fetch_lastfm("90s", "dance", force=True)
assert len(out) == 2
assert out[0]["artist"] == "Eiffel 65"
assert out[0]["title"] == "Blue (Da Ba Dee)"
# Ultima immagine della lista
assert out[0]["image_url"] == "large.jpg"
assert out[0]["popularity"] == "12345"
# Traccia senza immagini: image_url vuoto
assert out[1]["image_url"] == ""
@responses.activate
def test_fetch_lastfm_handles_empty_tracks(self, patched_cache):
responses.add(
responses.GET, charts._LASTFM_URL,
json={"tracks": {}}, status=200,
)
assert charts.fetch_lastfm("70s", "rock", force=True) == []
@responses.activate
def test_fetch_lastfm_returns_empty_on_error(self, patched_cache):
responses.add(responses.GET, charts._LASTFM_URL, status=500)
assert charts.fetch_lastfm("80s", "pop", force=True) == []
@responses.activate
def test_fetch_lastfm_uses_cache(self, patched_cache):
responses.add(responses.GET, charts._LASTFM_URL, json=self._SAMPLE, status=200)
charts.fetch_lastfm("2000s", "house", force=False)
charts.fetch_lastfm("2000s", "house", force=False)
assert len(responses.calls) == 1
@responses.activate
def test_fetch_lastfm_sends_configured_tag(self, patched_cache):
responses.add(responses.GET, charts._LASTFM_URL, json=self._SAMPLE, status=200)
charts.fetch_lastfm("90s", "dance", force=True)
assert "tag=90s+dance" in responses.calls[0].request.url
# ==================================================================
# Cache TTL
# ==================================================================
class TestCacheTtl:
@responses.activate
def test_cache_ttl_expires(self, patched_cache):
sample = {"feed": {"results": []}}
responses.add(
responses.GET,
charts._ITUNES_URL_TPL.format(country="it"),
json=sample, status=200,
)
responses.add(
responses.GET,
charts._ITUNES_URL_TPL.format(country="it"),
json=sample, status=200,
)
with freeze_time("2026-01-01 12:00:00"):
charts.fetch_itunes("it", force=False)
assert len(responses.calls) == 1
# +7h -> cache scaduta (TTL 6h)
with freeze_time("2026-01-01 19:00:00"):
charts.fetch_itunes("it", force=False)
assert len(responses.calls) == 2
# ==================================================================
# Spotify chart playlists
# ==================================================================
class TestSpotifyChartsRegistry:
def test_spotify_charts_dict_has_expected_keys(self):
expected = {
"top50_global", "top50_italy", "top50_usa", "top50_uk",
"viral50_global", "viral50_italy",
}
assert expected.issubset(charts._SPOTIFY_CHARTS.keys())
# Ognuno mappato a (playlist_id, label)
for k, v in charts._SPOTIFY_CHARTS.items():
assert isinstance(v, tuple) and len(v) == 2
assert v[0] and v[1]
class TestFetchSpotify:
_TRACKS = {
"items": [
{
"track": {
"name": "Track One",
"popularity": 88,
"artists": [{"name": "Artist X"}, {"name": "Artist Y"}],
"album": {
"name": "Album Z",
"images": [{"url": "https://spo/img.jpg"}],
},
}
},
{
"track": {
"name": "Track Two",
"popularity": 72,
"artists": [{"name": "Solo"}],
"album": {"name": "Alb2", "images": []},
}
},
]
}
@responses.activate
def test_fetch_spotify_parses_response(self, patched_cache, monkeypatch):
monkeypatch.setattr(
charts, "_spotify_credentials", lambda: ("cid", "csecret"),
)
monkeypatch.setattr(
"core.spotify_client.get_access_token",
lambda cid, csecret: "tok",
)
pid, _ = charts._SPOTIFY_CHARTS["top50_global"]
responses.add(
responses.GET,
f"https://api.spotify.com/v1/playlists/{pid}/tracks",
json=self._TRACKS,
status=200,
)
out = charts.fetch_spotify("top50_global", force=True)
assert len(out) == 2
assert out[0]["rank"] == 1
assert out[0]["artist"] == "Artist X, Artist Y"
assert out[0]["title"] == "Track One"
assert out[0]["album"] == "Album Z"
assert out[0]["image_url"] == "https://spo/img.jpg"
assert out[0]["popularity"] == 88
assert out[1]["image_url"] == ""
@responses.activate
def test_fetch_spotify_uses_cache(self, patched_cache, monkeypatch):
monkeypatch.setattr(
charts, "_spotify_credentials", lambda: ("cid", "csecret"),
)
monkeypatch.setattr(
"core.spotify_client.get_access_token",
lambda cid, csecret: "tok",
)
pid, _ = charts._SPOTIFY_CHARTS["top50_italy"]
responses.add(
responses.GET,
f"https://api.spotify.com/v1/playlists/{pid}/tracks",
json=self._TRACKS, status=200,
)
charts.fetch_spotify("top50_italy", force=False)
charts.fetch_spotify("top50_italy", force=False)
assert len(responses.calls) == 1
def test_fetch_spotify_returns_empty_if_no_creds(self, patched_cache, monkeypatch):
monkeypatch.setattr(charts, "_spotify_credentials", lambda: ("", ""))
assert charts.fetch_spotify("top50_global", force=True) == []
def test_fetch_spotify_returns_empty_if_invalid_key(self, patched_cache):
assert charts.fetch_spotify("not_a_real_key", force=True) == []
@responses.activate
def test_fetch_spotify_returns_empty_on_404(self, patched_cache, monkeypatch):
monkeypatch.setattr(
charts, "_spotify_credentials", lambda: ("cid", "csecret"),
)
monkeypatch.setattr(
"core.spotify_client.get_access_token",
lambda cid, csecret: "tok",
)
pid, _ = charts._SPOTIFY_CHARTS["viral50_global"]
responses.add(
responses.GET,
f"https://api.spotify.com/v1/playlists/{pid}/tracks",
status=404,
)
assert charts.fetch_spotify("viral50_global", force=True) == []
# ==================================================================
# M2O Chart
# ==================================================================
class TestFetchM2O:
_HTML = """
<html><body>
<main>
<article>
<div class="chart-left">
<span class="position equal">1</span>
<div class="text-list">
<h3 class="title small song">MOVIN' TO THE SUN</h3>
<h4 class="title xsmall author">HUGEL, IMAEL ANGEL, ULTRA NATE</h4>
</div>
</div>
<div class="chart-right">
<figure><img src="https://m2o/img1.jpg"></figure>
</div>
</article>
<article>
<div class="chart-left">
<span class="position">2</span>
<h3 class="title small song">SECOND SONG</h3>
<h4 class="title xsmall author">DJ TEST</h4>
</div>
<div class="chart-right">
<figure><img src="https://m2o/img2.jpg"></figure>
</div>
</article>
<article>
<div class="chart-left">
<span class="position">N/A</span>
<h3 class="title small song">INVALID</h3>
</div>
</article>
</main>
</body></html>
"""
@responses.activate
def test_fetch_m2o_parses_html(self, patched_cache):
responses.add(responses.GET, charts._M2O_URL, body=self._HTML, status=200)
out = charts.fetch_m2o(force=True)
# Il 3o article ha "N/A" come position -> skippato
assert len(out) == 2
assert out[0]["rank"] == 1
assert out[0]["title"] == "MOVIN' TO THE SUN"
assert out[0]["artist"] == "HUGEL, IMAEL ANGEL, ULTRA NATE"
assert out[0]["image_url"] == "https://m2o/img1.jpg"
assert out[0]["album"] == ""
assert out[0]["popularity"] is None
assert out[1]["rank"] == 2
@responses.activate
def test_fetch_m2o_skips_invalid_position(self, patched_cache):
# Solo 1 article valido nella pagina
responses.add(
responses.GET, charts._M2O_URL,
body="""<article>
<span class="position">not_a_number</span>
<h3 class="title small song">X</h3>
</article>""",
status=200,
)
assert charts.fetch_m2o(force=True) == []
@responses.activate
def test_fetch_m2o_uses_cache(self, patched_cache):
responses.add(responses.GET, charts._M2O_URL, body=self._HTML, status=200)
charts.fetch_m2o(force=False)
charts.fetch_m2o(force=False)
assert len(responses.calls) == 1
@responses.activate
def test_fetch_m2o_empty_on_error(self, patched_cache):
responses.add(responses.GET, charts._M2O_URL, status=500)
assert charts.fetch_m2o(force=True) == []
+148
View File
@@ -207,6 +207,17 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
}
.nav::-webkit-scrollbar-thumb:hover { background: var(--bg-input-hover); }
.nav-section-label {
padding: 12px 14px 6px;
font-size: 10px;
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-3);
user-select: none;
}
.nav-section-label:first-child { padding-top: 4px; }
.nav-item {
display: flex;
align-items: center;
@@ -2116,3 +2127,140 @@ input[type="number"]::-webkit-inner-spin-button {
border: 1px solid var(--border);
}
/* ==================================================================
* Charts tab (iTunes / Spotify / M2O / Last.fm)
* Riusa in gran parte le classi catalog-* per stile e comportamento.
* ================================================================== */
.charts-source-row {
display: flex;
gap: 18px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.charts-radio {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 14px;
color: var(--text-1);
cursor: pointer;
padding: 6px 10px;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
transition: background 0.15s ease, border-color 0.15s ease;
}
.charts-radio:hover {
background: var(--bg-elev);
}
.charts-radio input[type="radio"] {
accent-color: var(--indigo-hover, #6366F1);
cursor: pointer;
}
.charts-radio input[type="radio"]:checked ~ span {
color: var(--text-1);
font-weight: 600;
}
.charts-filters {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: flex-start;
margin-top: 8px;
}
.charts-filters[hidden] { display: none !important; }
.charts-actions-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.charts-actions-btns {
display: flex;
gap: 8px;
}
.charts-scroll {
max-height: 55vh;
overflow-y: auto;
overflow-x: auto;
padding-right: 6px;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--bg-card);
}
.charts-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
color: var(--text-2);
}
.charts-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-card-2);
color: var(--text-1);
font-weight: 700;
padding: 10px 8px;
text-align: left;
font-size: 11px;
letter-spacing: 0.3px;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.charts-table tbody td {
padding: 8px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 260px;
}
.charts-table tbody tr:hover {
background: var(--bg-elev);
}
.charts-col-check { width: 30px; text-align: center; }
.charts-col-rank { width: 42px; text-align: center; font-variant-numeric: tabular-nums; color: var(--text-3); font-weight: 700; }
.charts-col-pop { width: 110px; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-3); }
.charts-check {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--indigo-hover, #6366F1);
}
.charts-footer-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-top: 12px;
border-radius: var(--r-md);
background: var(--bg-card);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--border);
}
+189 -14
View File
@@ -21,6 +21,7 @@
<div class="divider"></div>
<nav class="nav">
<div class="nav-section-label">Scarica</div>
<button class="nav-item active" data-view="download" data-feature="audio">
<span class="nav-icon">⬇</span>
<span>Scarica</span>
@@ -29,14 +30,6 @@
<span class="nav-icon">🎬</span>
<span>Video</span>
</button>
<button class="nav-item" data-view="beatport" data-feature="audio">
<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>
@@ -45,10 +38,20 @@
<span class="nav-icon">▶</span>
<span>YouTube</span>
</button>
<button class="nav-item" data-view="convert" data-feature="audio">
<span class="nav-icon">🔄</span>
<span>Converti</span>
<button class="nav-item" data-view="beatport" data-feature="audio">
<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="charts" data-feature="audio">
<span class="nav-icon">📊</span>
<span>Charts</span>
</button>
<div class="nav-section-label">Organizza</div>
<button class="nav-item" data-view="dedup" data-feature="audio">
<span class="nav-icon">🗑</span>
<span>Dedup</span>
@@ -61,18 +64,26 @@
<span class="nav-icon">📤</span>
<span>Appiattisci</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
<button class="nav-item" data-view="convert" data-feature="audio">
<span class="nav-icon">🔄</span>
<span>Converti</span>
</button>
<button class="nav-item" data-view="meta" data-feature="metadata">
<span class="nav-icon">🏷</span>
<span>Metadati</span>
</button>
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
</button>
<div class="nav-section-label">Altro</div>
<button class="nav-item" data-view="record" data-feature="record">
<span class="nav-icon">●</span>
<span>Registra</span>
</button>
<div class="nav-section-label">Sistema</div>
<button class="nav-item" data-view="settings">
<span class="nav-icon">⚙</span>
<span>Impostazioni</span>
@@ -1497,6 +1508,170 @@
</div>
</section>
<!-- ===== VIEW: CHARTS ===== -->
<section class="view" id="view-charts">
<header class="hero hero-indigo">
<div class="hero-content">
<div class="hero-eyebrow indigo">CHARTS</div>
<h1 class="hero-title">Esplora le classifiche</h1>
<p class="hero-subtitle">Del momento (iTunes/Spotify), dance italiana (M2O) o storiche per genere (Last.fm) — aggiungi in un click alla coda Scarica.</p>
</div>
<div class="hero-deco">📊</div>
</header>
<h2 class="section-label">Sorgente</h2>
<div class="card">
<div class="charts-source-row">
<label class="charts-radio">
<input type="radio" name="charts-source" value="itunes" checked />
<span>🎵 Del momento (iTunes)</span>
</label>
<label class="charts-radio">
<input type="radio" name="charts-source" value="spotify" />
<span>🟢 Spotify Charts</span>
</label>
<label class="charts-radio">
<input type="radio" name="charts-source" value="m2o" />
<span>🔴 M2O Chart (dance italiana)</span>
</label>
<label class="charts-radio">
<input type="radio" name="charts-source" value="lastfm" />
<span>📻 Storiche per genere (Last.fm)</span>
</label>
</div>
<div class="charts-filters" id="charts-filters-itunes">
<div class="field inline">
<label class="field-label">Paese</label>
<select id="charts-itunes-country" class="input pill input-sm">
<option value="it">🇮🇹 Italia</option>
<option value="us">🇺🇸 USA</option>
<option value="gb">🇬🇧 UK</option>
<option value="ww">🌐 Mondo</option>
</select>
</div>
</div>
<div class="charts-filters" id="charts-filters-spotify" hidden>
<div class="field inline">
<label class="field-label">Playlist chart</label>
<select id="charts-spotify-playlist" class="input pill input-sm">
<option value="top50_global">Top 50 Global</option>
<option value="top50_italy">Top 50 Italy</option>
<option value="top50_usa">Top 50 USA</option>
<option value="top50_uk">Top 50 UK</option>
<option value="viral50_global">Viral 50 Global</option>
<option value="viral50_italy">Viral 50 Italy</option>
</select>
</div>
<div class="hint" style="margin-top:8px;">
<span class="hint-ico">ⓘ</span>
<div>
Le playlist Spotify richiedono le tue credenziali API (Impostazioni → Spotify).
Senza credenziali questa sorgente resta vuota.
</div>
</div>
</div>
<div class="charts-filters" id="charts-filters-m2o" hidden>
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
Chart dance italiana settimanale di M2O. Nessun filtro: premi "Aggiorna" per caricare la classifica corrente.
</div>
</div>
</div>
<div class="charts-filters" id="charts-filters-lastfm" hidden>
<div class="field inline">
<label class="field-label">Decade</label>
<select id="charts-lastfm-decade" class="input pill input-sm">
<option value="">Nessuna</option>
<option value="70s">Anni 70</option>
<option value="80s">Anni 80</option>
<option value="90s" selected>Anni 90</option>
<option value="2000s">Anni 2000</option>
<option value="2010s">Anni 2010</option>
</select>
</div>
<div class="field inline">
<label class="field-label">Genere</label>
<select id="charts-lastfm-genre" class="input pill input-sm">
<option value="">Nessuno</option>
<option value="pop">Pop</option>
<option value="rock">Rock</option>
<option value="dance" selected>Dance</option>
<option value="house">House</option>
<option value="hip hop">Hip Hop</option>
<option value="latin">Latin</option>
<option value="r&amp;b">R&amp;B</option>
<option value="italo disco">Italo Disco</option>
<option value="reggae">Reggae</option>
<option value="electronic">Electronic</option>
</select>
</div>
<div class="hint" style="margin-top:8px;">
<span class="hint-ico">ⓘ</span>
<div>
Last.fm richiede una API key: nell'app c'è una chiave demo, se non funziona
registrane una gratuita su <code>last.fm/api/account/create</code> e mettila nel campo
<code>lastfm_api_key</code> di <code>config.json</code>.
</div>
</div>
</div>
<div class="beatport-header" style="margin-top:14px;gap:12px;">
<button id="charts-fetch-btn" class="btn btn-primary pill">
<span class="ico">▶</span> Aggiorna classifica
</button>
<label style="display:flex;gap:6px;align-items:center;color:var(--text-2);font-size:13px;">
<input type="checkbox" id="charts-force" />
<span>Ignora cache (force refresh)</span>
</label>
<span id="charts-status" class="beatport-status"></span>
</div>
</div>
<div id="charts-results-card" class="card" hidden>
<div class="charts-actions-row">
<span id="charts-summary-line" class="catalog-summary"></span>
<div class="charts-actions-btns">
<button id="charts-select-all-btn" class="btn btn-ghost pill btn-sm">Seleziona tutti</button>
<button id="charts-deselect-all-btn" class="btn btn-ghost pill btn-sm">Deseleziona tutti</button>
</div>
</div>
<div id="charts-table-wrap" class="charts-scroll">
<table class="charts-table">
<thead>
<tr>
<th class="charts-col-check">
<input type="checkbox" id="charts-select-all" title="Seleziona/deseleziona tutti" />
</th>
<th class="charts-col-rank">#</th>
<th class="charts-col-artist">Artista</th>
<th class="charts-col-title">Titolo</th>
<th class="charts-col-album">Album</th>
<th class="charts-col-pop">Popolarità</th>
</tr>
</thead>
<tbody id="charts-table-body"></tbody>
</table>
</div>
</div>
<div class="charts-footer-bar" id="charts-footer-bar" hidden>
<span class="counter" id="charts-selected-count">0 brani selezionati</span>
<button id="charts-add-queue-btn" class="btn btn-primary pill" disabled>
<span class="ico">➕</span> Aggiungi alla coda Scarica
</button>
</div>
<h2 class="section-label">Log</h2>
<div class="card log-card">
<div class="log" id="charts-log"></div>
</div>
</section>
</main>
</div>
+358
View File
@@ -411,6 +411,7 @@ const logEls = {
dedup: () => $("#dedup-log"),
catalog: () => $("#catalog-log"),
flatten: () => $("#flatten-log"),
charts: () => $("#charts-log"),
};
function classifyLog(msg) {
@@ -542,6 +543,9 @@ async function init() {
// Cataloga tab (AcoustID -> <Anno>/<Genere>/)
await CatalogUI.init();
await FlattenUI.init();
// Charts tab (iTunes / Spotify / M2O / Last.fm)
await ChartsUI.init();
}
async function refreshRecDevices() {
@@ -3770,6 +3774,360 @@ const FlattenUI = (() => {
return { init };
})();
// ============================================================
// ChartsUI — classifiche iTunes / Spotify / M2O / Last.fm
// ============================================================
const ChartsUI = (() => {
const cs = {
source: "itunes",
country: "it",
playlist: "top50_global",
decade: "90s",
genre: "dance",
entries: [],
selected: new Set(), // Set di rank (int)
fetching: false,
};
function setStatus(text, kind) {
const el = $("#charts-status");
if (!el) return;
el.textContent = text || "";
el.className = "beatport-status" + (kind ? " " + kind : "");
}
function _showFiltersFor(source) {
const map = {
itunes: "#charts-filters-itunes",
spotify: "#charts-filters-spotify",
m2o: "#charts-filters-m2o",
lastfm: "#charts-filters-lastfm",
};
Object.values(map).forEach((sel) => {
const el = $(sel);
if (el) el.hidden = true;
});
const activeEl = $(map[source]);
if (activeEl) activeEl.hidden = false;
}
function _updateSelectedCount() {
const n = cs.selected.size;
const cnt = $("#charts-selected-count");
if (cnt) {
cnt.textContent = n === 1
? "1 brano selezionato"
: `${n} brani selezionati`;
}
const btn = $("#charts-add-queue-btn");
if (btn) {
btn.disabled = n === 0 || cs.fetching;
const suffix = n > 0
? ` Aggiungi ${n} alla coda Scarica`
: " Aggiungi alla coda Scarica";
btn.innerHTML = `<span class="ico">➕</span>${suffix}`;
}
const bar = $("#charts-footer-bar");
if (bar) bar.hidden = cs.entries.length === 0;
}
function _updateSummary() {
const line = $("#charts-summary-line");
if (!line) return;
const n = cs.entries.length;
if (n === 0) {
line.textContent = "";
return;
}
const labelBySource = {
itunes: "iTunes",
spotify: "Spotify Charts",
m2o: "M2O Chart",
lastfm: "Last.fm",
};
const src = labelBySource[cs.source] || cs.source;
line.textContent = `${n} brani da ${src}`;
}
function _fmtPopularity(entry) {
if (entry.popularity === null || entry.popularity === undefined || entry.popularity === "") {
return String(entry.rank || "—");
}
const n = Number(entry.popularity);
if (!isFinite(n)) return String(entry.popularity);
if (n >= 1000) {
// Formatta grandi numeri con separatore
return n.toLocaleString();
}
return String(n);
}
function _renderRow(entry) {
const tr = document.createElement("tr");
tr.dataset.rank = String(entry.rank);
// Checkbox
const tdCheck = document.createElement("td");
tdCheck.className = "charts-col-check";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "charts-check";
cb.checked = cs.selected.has(entry.rank);
cb.addEventListener("change", () => {
if (cb.checked) cs.selected.add(entry.rank);
else cs.selected.delete(entry.rank);
_updateSelectedCount();
});
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
// Rank
const tdRank = document.createElement("td");
tdRank.className = "charts-col-rank";
tdRank.textContent = String(entry.rank || "—");
tr.appendChild(tdRank);
// Artist
const tdArtist = document.createElement("td");
tdArtist.className = "charts-col-artist";
tdArtist.textContent = entry.artist || "—";
tdArtist.title = entry.artist || "";
tr.appendChild(tdArtist);
// Title
const tdTitle = document.createElement("td");
tdTitle.className = "charts-col-title";
tdTitle.textContent = entry.title || "—";
tdTitle.title = entry.title || "";
tr.appendChild(tdTitle);
// Album
const tdAlbum = document.createElement("td");
tdAlbum.className = "charts-col-album";
tdAlbum.textContent = entry.album || "—";
tdAlbum.title = entry.album || "";
tr.appendChild(tdAlbum);
// Popularity
const tdPop = document.createElement("td");
tdPop.className = "charts-col-pop";
tdPop.textContent = _fmtPopularity(entry);
tr.appendChild(tdPop);
return tr;
}
function _renderTable() {
const body = $("#charts-table-body");
const card = $("#charts-results-card");
if (!body || !card) return;
body.innerHTML = "";
if (cs.entries.length === 0) {
card.hidden = true;
const bar = $("#charts-footer-bar");
if (bar) bar.hidden = true;
return;
}
for (const entry of cs.entries) {
body.appendChild(_renderRow(entry));
}
card.hidden = false;
_updateSummary();
_updateSelectedCount();
}
function _params() {
if (cs.source === "itunes") return { country: cs.country };
if (cs.source === "spotify") return { playlist_key: cs.playlist };
if (cs.source === "m2o") return {};
if (cs.source === "lastfm") return { decade: cs.decade, genre: cs.genre };
return {};
}
async function doFetch() {
if (cs.fetching) return;
cs.fetching = true;
cs.entries = [];
cs.selected.clear();
_renderTable();
const btn = $("#charts-fetch-btn");
if (btn) btn.disabled = true;
setStatus("Caricamento classifica…", "loading");
const force = $("#charts-force")?.checked || false;
const payload = { source: cs.source, force, ...(_params()) };
let res;
try {
res = await window.pywebview.api.charts_fetch(payload);
} catch (e) {
setStatus("Errore: " + ((e && e.message) || e), "error");
cs.fetching = false;
if (btn) btn.disabled = false;
return;
}
cs.fetching = false;
if (btn) btn.disabled = false;
if (!res || !res.ok) {
const msg = (res && res.error) || "Impossibile caricare la classifica";
setStatus(msg, "error");
toast(msg, "error");
return;
}
cs.entries = Array.isArray(res.entries) ? res.entries : [];
_renderTable();
if (cs.entries.length === 0) {
setStatus("Nessun risultato. Prova a cambiare filtri o sorgente.", "ok");
} else {
setStatus(`${cs.entries.length} brani ricevuti`, "ok");
}
}
async function addToQueue() {
if (cs.selected.size === 0) return;
const rankSet = cs.selected;
const selectedEntries = cs.entries
.filter((e) => rankSet.has(e.rank))
.map((e) => ({ artist: e.artist, title: e.title }));
if (selectedEntries.length === 0) return;
let res;
try {
res = await window.pywebview.api.charts_add_to_queue(selectedEntries);
} catch (e) {
toast("Errore: " + ((e && e.message) || e), "error");
return;
}
if (!res || !res.ok) {
toast((res && res.error) || "Errore aggiunta", "error");
return;
}
// Il bridge emette `charts:add_to_queue` che poi popola la tab Scarica.
// Reset selezione qui.
cs.selected.clear();
// Deseleziona checkbox visibili
$$("#charts-table-body .charts-check").forEach((cb) => { cb.checked = false; });
const master = $("#charts-select-all");
if (master) master.checked = false;
_updateSelectedCount();
}
function _selectAll(on) {
cs.selected.clear();
if (on) {
for (const e of cs.entries) cs.selected.add(e.rank);
}
$$("#charts-table-body .charts-check").forEach((cb) => { cb.checked = on; });
const master = $("#charts-select-all");
if (master) master.checked = on;
_updateSelectedCount();
}
async function init() {
// Ripristina scelta da config
const cfg = state.config || {};
cs.source = cfg.charts_last_source || "itunes";
cs.country = cfg.charts_itunes_country || "it";
cs.playlist = cfg.charts_spotify_playlist || "top50_global";
cs.decade = cfg.charts_lastfm_decade || "90s";
cs.genre = cfg.charts_lastfm_genre || "dance";
// Applica valori ai controlli
const radio = document.querySelector(`input[name="charts-source"][value="${cs.source}"]`);
if (radio) radio.checked = true;
else {
// Fallback: seleziona iTunes
const fallback = document.querySelector('input[name="charts-source"][value="itunes"]');
if (fallback) fallback.checked = true;
cs.source = "itunes";
}
_showFiltersFor(cs.source);
if ($("#charts-itunes-country")) $("#charts-itunes-country").value = cs.country;
if ($("#charts-spotify-playlist")) $("#charts-spotify-playlist").value = cs.playlist;
if ($("#charts-lastfm-decade")) $("#charts-lastfm-decade").value = cs.decade;
if ($("#charts-lastfm-genre")) $("#charts-lastfm-genre").value = cs.genre;
// Bind eventi source radio
document.querySelectorAll('input[name="charts-source"]').forEach((r) => {
r.addEventListener("change", () => {
if (!r.checked) return;
cs.source = r.value;
_showFiltersFor(cs.source);
});
});
// Bind filtri
$("#charts-itunes-country")?.addEventListener("change", (e) => {
cs.country = e.target.value;
});
$("#charts-spotify-playlist")?.addEventListener("change", (e) => {
cs.playlist = e.target.value;
});
$("#charts-lastfm-decade")?.addEventListener("change", (e) => {
cs.decade = e.target.value;
});
$("#charts-lastfm-genre")?.addEventListener("change", (e) => {
cs.genre = e.target.value;
});
// Fetch, seleziona, aggiungi
$("#charts-fetch-btn")?.addEventListener("click", doFetch);
$("#charts-add-queue-btn")?.addEventListener("click", addToQueue);
$("#charts-select-all-btn")?.addEventListener("click", () => _selectAll(true));
$("#charts-deselect-all-btn")?.addEventListener("click", () => _selectAll(false));
$("#charts-select-all")?.addEventListener("change", (e) => _selectAll(!!e.target.checked));
// Bridge handler: quando l'utente aggiunge alla coda, popola la tab Scarica.
bridgeHandlers["charts:add_to_queue"] = (p) => {
if (!p) return;
const tracks = Array.isArray(p.tracks) ? p.tracks : null;
const text = typeof p.text === "string" ? p.text : "";
const count = Number(p.count) || (tracks ? tracks.length : 0);
// Popola state.loaded come "tracks" (stesso pattern del "Carica lista")
if (tracks && tracks.length) {
state.loaded = {
ok: true,
kind: "tracks",
tracks: tracks,
count: tracks.length,
name: "charts",
};
// Aggiorna il campo URL e il badge sulla tab Scarica
const urlInput = $("#urlInput");
if (urlInput) {
urlInput.value = "[Charts] " + count + " brani";
}
const badge = $("#urlListBadge");
if (badge) {
badge.textContent = `· ${count} tracce`;
badge.hidden = false;
}
appendLog("download", `[INFO] Caricati ${count} brani dalla tab Charts`);
} else if (text) {
// Fallback: nessun tracks strutturati, usa il testo grezzo
const urlInput = $("#urlInput");
if (urlInput) {
urlInput.value = urlInput.value ? urlInput.value + "\n" + text : text;
}
}
// Naviga alla tab Scarica
showView("download");
toast(`${count} brani aggiunti alla coda Scarica`, "success");
};
_updateSelectedCount();
}
return { init };
})();
// ============================================================
// Boot
// ============================================================