Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9644ffec35 | ||
|
|
8432b2a592 | ||
|
|
39e843dc69 | ||
|
|
29c177ff69 | ||
|
|
75e3da5f82 | ||
|
|
29b9753aa3 | ||
|
|
c88e6d359c |
No files matched your search
+121
@@ -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 = """\
|
||||
@@ -445,6 +446,21 @@ class Api:
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def remove_metadata_frames(self, path: str, frame_keys: list) -> dict:
|
||||
"""Rimuove i frame ID3/atomi indicati dal file. Usato dal pannello
|
||||
'Metadati avanzati' per pulire TXXX, GEOB (C2PA), USLT extra, ecc."""
|
||||
if not path or not os.path.exists(path):
|
||||
return {"ok": False, "error": "File non trovato"}
|
||||
gate = self._gate("metadata")
|
||||
if gate:
|
||||
return gate
|
||||
try:
|
||||
from core.metadata import remove_frames
|
||||
n = remove_frames(path, frame_keys or [])
|
||||
return {"ok": True, "removed": n}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def save_metadata(self, payload: dict) -> dict:
|
||||
path = (payload.get("path") or "").strip()
|
||||
if not path:
|
||||
@@ -2263,3 +2279,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)}
|
||||
+10
-8
@@ -39,21 +39,23 @@ def log(msg):
|
||||
# 1. Scarica yt-dlp standalone
|
||||
# =========================================================================
|
||||
def download_ytdlp():
|
||||
"""Scarica il binario standalone di yt-dlp per macOS."""
|
||||
"""Scarica il binario standalone di yt-dlp per macOS.
|
||||
|
||||
Usa il NIGHTLY channel invece dello stable perche' YouTube rompe le
|
||||
API ogni 1-2 settimane e la stable resta indietro di 3-4 settimane
|
||||
tipicamente. La nightly esce quasi ogni giorno con i fix.
|
||||
Sempre re-download: garantisce che ogni build imbarca yt-dlp fresco.
|
||||
"""
|
||||
arch = platform.machine() # arm64 o x86_64
|
||||
if arch == "arm64":
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||
url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos"
|
||||
else:
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos_legacy"
|
||||
url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos_legacy"
|
||||
|
||||
dest = BUNDLE_DIR / "yt-dlp"
|
||||
if dest.exists():
|
||||
log(f"yt-dlp gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
log(f"Scarico yt-dlp da {url} ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log(f"Scarico yt-dlp nightly da {url} ...")
|
||||
# Usa curl perche urllib di Python 3.8 ha problemi SSL su macOS
|
||||
subprocess.run(
|
||||
["curl", "-L", "-o", str(dest), url],
|
||||
|
||||
+4
-6
@@ -29,7 +29,7 @@ BUILD_DIR = ROOT / "build"
|
||||
DIST_DIR = ROOT / "dist"
|
||||
BUNDLE_DIR = ROOT / "bundle_bin"
|
||||
|
||||
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
YTDLP_URL = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp.exe"
|
||||
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
|
||||
FPCALC_URL = ("https://github.com/acoustid/chromaprint/releases/download/"
|
||||
"v1.5.1/chromaprint-fpcalc-1.5.1-windows-x86_64.zip")
|
||||
@@ -43,13 +43,11 @@ def log(msg):
|
||||
# 1. Scarica yt-dlp.exe
|
||||
# =========================================================================
|
||||
def download_ytdlp():
|
||||
"""Scarica sempre il nightly fresco: la stable resta indietro di
|
||||
3-4 settimane rispetto alle rotture API YouTube."""
|
||||
dest = BUNDLE_DIR / "yt-dlp.exe"
|
||||
if dest.exists():
|
||||
log(f"yt-dlp.exe gia presente: {dest}")
|
||||
return dest
|
||||
|
||||
log(f"Scarico yt-dlp.exe ...")
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log(f"Scarico yt-dlp.exe nightly ...")
|
||||
urllib.request.urlretrieve(YTDLP_URL, dest)
|
||||
log(f"yt-dlp.exe scaricato ({dest.stat().st_size // 1024 // 1024} MB)")
|
||||
return dest
|
||||
|
||||
+437
@@ -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
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.11.1"
|
||||
VERSION = "v1.13.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
@@ -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:
|
||||
|
||||
@@ -14,6 +14,7 @@ from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import (
|
||||
ID3, ID3NoHeaderError,
|
||||
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
|
||||
USLT, WOAS,
|
||||
)
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from mutagen.flac import FLAC, Picture
|
||||
@@ -84,6 +85,69 @@ def _text(value) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
_AI_MARKERS = {
|
||||
"suno": ["suno.com", "made with suno", "sunoai"],
|
||||
"udio": ["udio.com", "made with udio"],
|
||||
"riffusion": ["riffusion.com", "made with riffusion"],
|
||||
"mubert": ["mubert.com", "made with mubert"],
|
||||
"aiva": ["aiva.ai"],
|
||||
"boomy": ["boomy.com", "made with boomy"],
|
||||
"soundraw": ["soundraw.io"],
|
||||
"loudly": ["loudly.com"],
|
||||
}
|
||||
|
||||
|
||||
def _detect_ai(comment: str, source_url: str, extra: str = "") -> tuple:
|
||||
"""Ritorna (is_ai: bool, source_name: str). Case-insensitive.
|
||||
`extra` = altre stringhe da controllare (es. presenza C2PA manifest)."""
|
||||
blob = f"{comment} {source_url} {extra}".lower()
|
||||
for source, markers in _AI_MARKERS.items():
|
||||
for m in markers:
|
||||
if m in blob:
|
||||
return True, source
|
||||
if "c2pa" in blob:
|
||||
return True, ""
|
||||
return False, ""
|
||||
|
||||
|
||||
def _dump_id3_frames(tags) -> list:
|
||||
"""Lista compatta di tutti i frame ID3 (esclusa APIC binaria).
|
||||
Ogni voce: {key, preview, size, kind}. Utile per audit AI-generated."""
|
||||
out = []
|
||||
for key in tags.keys():
|
||||
try:
|
||||
frame = tags[key]
|
||||
except Exception:
|
||||
continue
|
||||
kind = "text"
|
||||
preview = ""
|
||||
size = 0
|
||||
if key.startswith("APIC"):
|
||||
kind = "binary"
|
||||
preview = f"{frame.mime or 'image'} ({len(frame.data)} bytes)"
|
||||
size = len(frame.data)
|
||||
elif key.startswith("GEOB"):
|
||||
kind = "binary"
|
||||
data = getattr(frame, "data", b"") or b""
|
||||
desc = getattr(frame, "desc", "") or ""
|
||||
mime = getattr(frame, "mime", "") or ""
|
||||
preview = f"{desc or mime or 'blob'} ({len(data)} bytes)"
|
||||
size = len(data)
|
||||
elif key.startswith("USLT"):
|
||||
txt = getattr(frame, "text", "") or ""
|
||||
preview = txt[:180] + ("…" if len(txt) > 180 else "")
|
||||
size = len(txt)
|
||||
elif key.startswith("WOAS") or key.startswith("WXXX") or key.startswith("WOAR"):
|
||||
preview = getattr(frame, "url", "") or str(frame)
|
||||
size = len(preview)
|
||||
else:
|
||||
txt = str(frame)
|
||||
preview = txt[:180] + ("…" if len(txt) > 180 else "")
|
||||
size = len(txt)
|
||||
out.append({"key": key, "preview": preview, "size": size, "kind": kind})
|
||||
return out
|
||||
|
||||
|
||||
def _read_mp3(path: str, result: dict) -> dict:
|
||||
result["format"] = "MP3"
|
||||
try:
|
||||
@@ -91,6 +155,8 @@ def _read_mp3(path: str, result: dict) -> dict:
|
||||
except ID3NoHeaderError:
|
||||
return result
|
||||
|
||||
result["raw_frames"] = _dump_id3_frames(tags)
|
||||
|
||||
result["title"] = _text(tags.get("TIT2"))
|
||||
result["artist"] = _text(tags.get("TPE1"))
|
||||
result["album_artist"] = _text(tags.get("TPE2"))
|
||||
@@ -100,12 +166,19 @@ def _read_mp3(path: str, result: dict) -> dict:
|
||||
result["genre"] = _text(tags.get("TCON"))
|
||||
result["bpm"] = _text(tags.get("TBPM"))
|
||||
result["key"] = _text(tags.get("TKEY"))
|
||||
result["source_url"] = _text(tags.get("WOAS"))
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("COMM"):
|
||||
result["comment"] = _text(tags[k])
|
||||
break
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("USLT"):
|
||||
frame = tags[k]
|
||||
result["lyrics"] = getattr(frame, "text", "") or ""
|
||||
break
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("APIC"):
|
||||
apic = tags[k]
|
||||
@@ -113,13 +186,44 @@ def _read_mp3(path: str, result: dict) -> dict:
|
||||
result["cover_mime"] = apic.mime or "image/jpeg"
|
||||
break
|
||||
|
||||
# AI detection: C2PA manifest (GEOB frame) o markers testuali
|
||||
has_c2pa = any("c2pa" in k.lower() for k in tags.keys())
|
||||
result["ai_generated"], result["ai_source"] = _detect_ai(
|
||||
result["comment"], result["source_url"], "c2pa" if has_c2pa else "",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _dump_mp4_atoms(tags) -> list:
|
||||
out = []
|
||||
for key, val in tags.items():
|
||||
preview = ""
|
||||
kind = "text"
|
||||
size = 0
|
||||
if key == "covr":
|
||||
kind = "binary"
|
||||
data = bytes(val[0]) if val else b""
|
||||
preview = f"cover ({len(data)} bytes)"
|
||||
size = len(data)
|
||||
else:
|
||||
try:
|
||||
preview = str(val[0] if isinstance(val, list) and val else val)
|
||||
except Exception:
|
||||
preview = "<binary>"
|
||||
if isinstance(preview, str):
|
||||
size = len(preview)
|
||||
if len(preview) > 180:
|
||||
preview = preview[:180] + "…"
|
||||
out.append({"key": key, "preview": preview, "size": size, "kind": kind})
|
||||
return out
|
||||
|
||||
|
||||
def _read_mp4(path: str, result: dict) -> dict:
|
||||
result["format"] = "M4A/MP4"
|
||||
m = MP4(path)
|
||||
tags = m.tags or {}
|
||||
result["raw_frames"] = _dump_mp4_atoms(tags)
|
||||
result["title"] = _text(tags.get("\xa9nam"))
|
||||
result["artist"] = _text(tags.get("\xa9ART"))
|
||||
result["album_artist"] = _text(tags.get("aART"))
|
||||
@@ -132,12 +236,24 @@ def _read_mp4(path: str, result: dict) -> dict:
|
||||
result["genre"] = _text(tags.get("\xa9gen"))
|
||||
result["comment"] = _text(tags.get("\xa9cmt"))
|
||||
result["bpm"] = _text(tags.get("tmpo"))
|
||||
result["lyrics"] = _text(tags.get("\xa9lyr"))
|
||||
# M4A non ha WOAS: cerchiamo in freeform ----:com.apple.iTunes:URL
|
||||
for k in tags.keys():
|
||||
if isinstance(k, str) and k.startswith("----") and "URL" in k.upper():
|
||||
v = tags[k]
|
||||
if v:
|
||||
result["source_url"] = v[0].decode("utf-8", errors="ignore") if isinstance(v[0], bytes) else str(v[0])
|
||||
break
|
||||
|
||||
covers = tags.get("covr")
|
||||
if covers:
|
||||
c = covers[0]
|
||||
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
|
||||
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png"
|
||||
|
||||
result["ai_generated"], result["ai_source"] = _detect_ai(
|
||||
result["comment"], result["source_url"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -292,8 +408,13 @@ def read_metadata(path: str) -> dict:
|
||||
"bpm": "", "key": "",
|
||||
"duration": 0, "bitrate": 0,
|
||||
"cover_base64": "", "cover_mime": "",
|
||||
"lyrics": "", # USLT (MP3), \xa9lyr (M4A), UNSYNCEDLYRICS (FLAC)
|
||||
"source_url": "", # WOAS (MP3), custom (altri) — es. URL Suno
|
||||
"ai_generated": False, # True se rilevato marker AI (C2PA, "made with suno"...)
|
||||
"ai_source": "", # es. "suno", "udio", "riffusion" — sorgente dedotta
|
||||
"where_from": _read_where_from(str(p)),
|
||||
"is_macos": sys.platform == "darwin",
|
||||
"raw_frames": [], # list of {key, preview, size, kind} — tutti i frame ID3/atomi
|
||||
}
|
||||
|
||||
audio = MutagenFile(str(p))
|
||||
@@ -346,6 +467,22 @@ def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: b
|
||||
if comment:
|
||||
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
|
||||
|
||||
# Lyrics (USLT). Rimuovi tutti gli USLT esistenti, poi (ri)crea se non vuoto
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("USLT"):
|
||||
del tags[k]
|
||||
lyrics = data.get("lyrics", "")
|
||||
if lyrics:
|
||||
tags.add(USLT(encoding=3, lang="eng", desc="", text=lyrics))
|
||||
|
||||
# Source URL (WOAS)
|
||||
if "source_url" in data:
|
||||
src = data.get("source_url", "")
|
||||
if "WOAS" in tags:
|
||||
del tags["WOAS"]
|
||||
if src:
|
||||
tags.add(WOAS(url=src))
|
||||
|
||||
# Cover
|
||||
if remove_cover or cover_path:
|
||||
for k in list(tags.keys()):
|
||||
@@ -379,6 +516,7 @@ def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: b
|
||||
set_or_del("\xa9day", data.get("year", ""))
|
||||
set_or_del("\xa9gen", data.get("genre", ""))
|
||||
set_or_del("\xa9cmt", data.get("comment", ""))
|
||||
set_or_del("\xa9lyr", data.get("lyrics", ""))
|
||||
|
||||
track = data.get("track", "")
|
||||
if track:
|
||||
@@ -518,3 +656,38 @@ def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
|
||||
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
|
||||
if "where_from" in data:
|
||||
_write_where_from(path, data.get("where_from") or [])
|
||||
|
||||
|
||||
def remove_frames(path: str, frame_keys: list) -> int:
|
||||
"""Rimuove specifici frame ID3 (MP3) o atomi (MP4) dal file.
|
||||
Ritorna il numero di frame rimossi. `frame_keys` sono le chiavi
|
||||
esatte così come tornano da `raw_frames` (es. "TXXX:comment",
|
||||
"GEOB:c2pa manifest store", "\xa9lyr")."""
|
||||
if not frame_keys:
|
||||
return 0
|
||||
ext = Path(path).suffix.lower()
|
||||
removed = 0
|
||||
if ext == ".mp3":
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
return 0
|
||||
for key in frame_keys:
|
||||
if key in tags:
|
||||
del tags[key]
|
||||
removed += 1
|
||||
if removed:
|
||||
tags.save(path)
|
||||
elif ext in (".m4a", ".mp4", ".aac"):
|
||||
m = MP4(path)
|
||||
if m.tags is None:
|
||||
return 0
|
||||
for key in frame_keys:
|
||||
if key in m.tags:
|
||||
del m.tags[key]
|
||||
removed += 1
|
||||
if removed:
|
||||
m.save()
|
||||
else:
|
||||
raise ValueError(f"Rimozione frame non supportata per: {ext}")
|
||||
return removed
|
||||
+4
-10
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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;
|
||||
@@ -567,6 +578,87 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.path-display:empty,
|
||||
.path-display.empty { color: var(--text-3); }
|
||||
|
||||
.ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(135deg, var(--purple), var(--pink));
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(168, 85, 247, 0.35);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.ai-badge[hidden] { display: none !important; }
|
||||
.ai-badge::before { content: "🤖"; }
|
||||
|
||||
.raw-frames-details {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-card-2);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.raw-frames-details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
padding: 2px 0;
|
||||
user-select: none;
|
||||
}
|
||||
.raw-frames-details summary:hover { color: var(--text); }
|
||||
.raw-frames-details[open] summary { margin-bottom: 10px; color: var(--text); }
|
||||
.raw-frames-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.raw-frames-table th,
|
||||
.raw-frames-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.raw-frames-table th {
|
||||
color: var(--text-3);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.raw-frames-table td.raw-key {
|
||||
font-family: ui-monospace, SF Mono, Menlo, monospace;
|
||||
color: var(--indigo-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.raw-frames-table td.raw-preview {
|
||||
word-break: break-word;
|
||||
color: var(--text);
|
||||
}
|
||||
.raw-frames-table td.raw-size {
|
||||
text-align: right;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.raw-frames-table .kind-binary {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--amber-dim);
|
||||
color: var(--amber-text);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
BUTTONS
|
||||
=============================== */
|
||||
@@ -2116,3 +2208,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);
|
||||
}
|
||||
|
||||
+226
-14
@@ -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>
|
||||
@@ -433,6 +444,7 @@
|
||||
<div class="row">
|
||||
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
|
||||
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
|
||||
<span id="metaAiBadge" class="ai-badge" hidden title="Brano generato con AI"></span>
|
||||
</div>
|
||||
<div class="meta-info" id="metaInfo" hidden></div>
|
||||
</div>
|
||||
@@ -481,6 +493,42 @@
|
||||
<label class="field-label">Commento</label>
|
||||
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">URL sorgente (WOAS)</label>
|
||||
<input type="text" id="metaSourceUrl" class="input pill" placeholder="https://...">
|
||||
<div class="cover-hint">Link alla pagina d'origine del brano (es. Suno, YouTube, SoundCloud). Sopravvive alle copie del file.</div>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Testo / Prompt (USLT)</label>
|
||||
<textarea id="metaLyrics" class="input textarea" rows="8" placeholder="Lyrics del brano o prompt AI utilizzato..."></textarea>
|
||||
<div class="cover-hint">Testo o prompt strutturato. Suno lo compila automaticamente al momento della generazione.</div>
|
||||
</div>
|
||||
<details class="field full raw-frames-details">
|
||||
<summary>Metadati avanzati (tutti i frame ID3 grezzi)</summary>
|
||||
<table class="raw-frames-table" id="metaRawFrames">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32px;"><input type="checkbox" id="metaRawSelectAll" title="Seleziona tutti"></th>
|
||||
<th>Frame</th>
|
||||
<th>Tipo</th>
|
||||
<th>Contenuto</th>
|
||||
<th style="text-align:right;">Bytes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="row" style="margin-top:10px;justify-content:space-between;align-items:center;">
|
||||
<span id="metaRawSelectedCount" style="font-size:12px;color:var(--text-3);">0 selezionati</span>
|
||||
<button id="metaRawRemoveBtn" class="btn btn-danger pill btn-sm" disabled>
|
||||
🗑 Rimuovi selezionati
|
||||
</button>
|
||||
</div>
|
||||
<div class="cover-hint" style="margin-top:8px;">
|
||||
I frame standard (TIT2 titolo, TPE1 artista, TALB album, APIC cover…) si gestiscono con i campi sopra.
|
||||
Qui rimuovi frame custom come TXXX, GEOB (C2PA), USLT (lyrics) o COMM extra.
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="field full" id="metaWhereFromField" hidden>
|
||||
<label class="field-label">
|
||||
Origine (macOS · kMDItemWhereFroms)
|
||||
@@ -1497,6 +1545,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&b">R&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>
|
||||
|
||||
|
||||
+450
@@ -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() {
|
||||
@@ -1143,6 +1147,51 @@ function populateMetaForm(d) {
|
||||
$("#metaBpm").value = d.bpm || "";
|
||||
$("#metaKey").value = d.key || "";
|
||||
$("#metaComment").value = d.comment || "";
|
||||
$("#metaSourceUrl").value = d.source_url || "";
|
||||
$("#metaLyrics").value = d.lyrics || "";
|
||||
|
||||
// Raw frames table
|
||||
const rawBody = document.querySelector("#metaRawFrames tbody");
|
||||
if (rawBody) {
|
||||
// Frame gestiti dai campi standard: no checkbox (li rimuovi svuotando il campo)
|
||||
const PROTECTED_MP3 = /^(TIT2|TPE1|TPE2|TALB|TDRC|TRCK|TCON|TBPM|TKEY|APIC)/;
|
||||
const PROTECTED_MP4 = new Set(["\xa9nam","\xa9ART","aART","\xa9alb","\xa9day","trkn","\xa9gen","tmpo","covr"]);
|
||||
rawBody.innerHTML = "";
|
||||
(d.raw_frames || []).forEach((f) => {
|
||||
const tr = document.createElement("tr");
|
||||
const kindHtml = f.kind === "binary"
|
||||
? '<span class="kind-binary">binary</span>'
|
||||
: '<span style="color:var(--text-3);font-size:11px;">text</span>';
|
||||
const isProtected = PROTECTED_MP3.test(f.key) || PROTECTED_MP4.has(f.key);
|
||||
const cbHtml = isProtected
|
||||
? '<span title="Frame standard — gestito dai campi sopra" style="color:var(--text-3);font-size:14px;">🔒</span>'
|
||||
: `<input type="checkbox" class="meta-raw-check" data-key="${_escapeHtml(f.key)}">`;
|
||||
tr.innerHTML = `
|
||||
<td style="text-align:center;">${cbHtml}</td>
|
||||
<td class="raw-key">${_escapeHtml(f.key)}</td>
|
||||
<td>${kindHtml}</td>
|
||||
<td class="raw-preview">${_escapeHtml(f.preview || "")}</td>
|
||||
<td class="raw-size">${(f.size || 0).toLocaleString()}</td>
|
||||
`;
|
||||
rawBody.appendChild(tr);
|
||||
});
|
||||
// Reset stato selezione
|
||||
const master = $("#metaRawSelectAll");
|
||||
if (master) { master.checked = false; master.indeterminate = false; }
|
||||
_updateRawFramesSelection();
|
||||
}
|
||||
|
||||
// AI badge
|
||||
const aiBadge = $("#metaAiBadge");
|
||||
if (aiBadge) {
|
||||
if (d.ai_generated) {
|
||||
aiBadge.hidden = false;
|
||||
aiBadge.textContent = d.ai_source ? `AI · ${d.ai_source}` : "AI Generated";
|
||||
} else {
|
||||
aiBadge.hidden = true;
|
||||
aiBadge.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// WhereFroms (macOS only)
|
||||
if (d.is_macos) {
|
||||
@@ -1200,6 +1249,51 @@ $("#metaPickBtn").addEventListener("click", async () => {
|
||||
if (path) await loadMetaFile(path);
|
||||
});
|
||||
|
||||
function _updateRawFramesSelection() {
|
||||
const boxes = $$("#metaRawFrames .meta-raw-check");
|
||||
const checked = boxes.filter((b) => b.checked);
|
||||
const cnt = $("#metaRawSelectedCount");
|
||||
if (cnt) cnt.textContent = `${checked.length} selezionati`;
|
||||
const btn = $("#metaRawRemoveBtn");
|
||||
if (btn) btn.disabled = checked.length === 0;
|
||||
const master = $("#metaRawSelectAll");
|
||||
if (master) {
|
||||
if (boxes.length === 0) { master.checked = false; master.indeterminate = false; }
|
||||
else if (checked.length === 0) { master.checked = false; master.indeterminate = false; }
|
||||
else if (checked.length === boxes.length) { master.checked = true; master.indeterminate = false; }
|
||||
else { master.checked = false; master.indeterminate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("change", (e) => {
|
||||
const t = e.target;
|
||||
if (t && t.classList && t.classList.contains("meta-raw-check")) {
|
||||
_updateRawFramesSelection();
|
||||
}
|
||||
});
|
||||
|
||||
$("#metaRawSelectAll")?.addEventListener("change", (e) => {
|
||||
$$("#metaRawFrames .meta-raw-check").forEach((b) => { b.checked = e.target.checked; });
|
||||
_updateRawFramesSelection();
|
||||
});
|
||||
|
||||
$("#metaRawRemoveBtn")?.addEventListener("click", async () => {
|
||||
const keys = $$("#metaRawFrames .meta-raw-check")
|
||||
.filter((b) => b.checked)
|
||||
.map((b) => b.dataset.key);
|
||||
if (keys.length === 0) return;
|
||||
if (!window.confirm(`Rimuovere ${keys.length} frame dal file?\n\n${keys.join("\n")}\n\nOperazione permanente.`)) return;
|
||||
const path = state.meta?.path;
|
||||
if (!path) return;
|
||||
const res = await window.pywebview.api.remove_metadata_frames(path, keys);
|
||||
if (res && res.ok) {
|
||||
toast(`Rimossi ${res.removed || 0} frame`, "ok");
|
||||
await loadMetaFile(path); // ricarica per aggiornare tabella
|
||||
} else {
|
||||
toast("Errore: " + (res?.error || "sconosciuto"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
$("#metaCoverBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.pick_image_file();
|
||||
if (!path) return;
|
||||
@@ -1250,6 +1344,8 @@ $("#metaSaveBtn").addEventListener("click", async () => {
|
||||
bpm: $("#metaBpm").value,
|
||||
key: $("#metaKey").value,
|
||||
comment: $("#metaComment").value,
|
||||
source_url: $("#metaSourceUrl").value.trim(),
|
||||
lyrics: $("#metaLyrics").value,
|
||||
where_from: $("#metaWhereFrom").value
|
||||
.split("\n")
|
||||
.map(s => s.trim())
|
||||
@@ -3770,6 +3866,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
|
||||
// ============================================================
|
||||
|
||||
Reference in new issue
Block a user